Merge pull request #49 from remnawave:development

Introduce metadata management and enhance validation
This commit is contained in:
Artem 2026-03-28 20:16:37 +01:00 committed by GitHub
commit bb337aa723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2121 additions and 215 deletions

View file

@ -3,5 +3,7 @@
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python-envs.defaultEnvManager": "ms-python.python:poetry",
"python-envs.defaultPackageManager": "ms-python.python:poetry"
}

View file

@ -50,6 +50,7 @@ pip install git+https://github.com/remnawave/python-sdk.git@development
| Contract Version | Remnawave Panel Version |
| ---------------- | ----------------------- |
| 2.7.0 | >=2.7.0 |
| 2.6.3 | >=2.6.3 |
| 2.6.2 | >=2.6.2 |
| 2.6.1 | >=2.6.0 |

View file

@ -1,7 +1,7 @@
[project]
name = "remnawave"
version = "2.6.3"
description = "A Python SDK for interacting with the Remnawave API v2.6.3."
version = "2.7.0"
description = "A Python SDK for interacting with the Remnawave API v2.7.0."
authors = [
{name = "Artem",email = "dev@forestsnet.com"}
]

View file

@ -33,6 +33,8 @@ from remnawave.controllers import (
RemnawaveSettingsController,
SubscriptionPageConfigController,
IpControlController,
NodePluginsController,
MetadataController,
)
@ -101,6 +103,8 @@ class RemnawaveSDK:
self.remnawave_settings = RemnawaveSettingsController(self._client)
self.subscription_page_config = SubscriptionPageConfigController(self._client)
self.ip_control = IpControlController(self._client)
self.node_plugins = NodePluginsController(self._client)
self.metadata = MetadataController(self._client)
def _validate_params(self) -> None:
if self._client is None:

View file

@ -27,6 +27,8 @@ from .snippets import SnippetsController
from .remnawave_settings import RemnawaveSettingsController
from .subscription_page import SubscriptionPageConfigController
from .ip_control import IpControlController
from .node_plugins import NodePluginsController
from .metadata import MetadataController
__all__ = [
"APITokensManagementController",
@ -58,4 +60,6 @@ __all__ = [
"RemnawaveSettingsController",
"SubscriptionPageConfigController",
"IpControlController",
"NodePluginsController",
"MetadataController",
]

View file

@ -56,10 +56,10 @@ class HWIDUserController(BaseController):
"""Delete all user HWID devices"""
...
@get("/hwid/devices/{uuid}", response_class=GetUserHwidDevicesResponseDto)
@get("/hwid/devices/{userUuid}", response_class=GetUserHwidDevicesResponseDto)
async def get_hwid_user(
self,
uuid: Annotated[str, Path(description="UUID of the User")],
uuid: Annotated[str, Path(description="UUID of the User", alias="userUuid")],
) -> GetUserHwidDevicesResponseDto:
"""Get a user HWID device"""
...

View file

@ -8,6 +8,8 @@ from remnawave.models import (
DropConnectionsResponseDto,
FetchIpsResponseDto,
FetchIpsResultResponseDto,
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
)
from remnawave.rapid import BaseController, get, post
@ -41,6 +43,33 @@ class IpControlController(BaseController):
"""
...
@post("/ip-control/fetch-users-ips/{nodeUuid}", response_class=FetchUsersIpsResponseDto)
async def fetch_users_ips(
self,
nodeUuid: Annotated[str, Path(description="UUID of the node")],
) -> FetchUsersIpsResponseDto:
"""Request IP List for all users on a node.
Starts a background job that queries the specified node for the IPs
of all connected users. The returned ``job_id`` must be passed to
:meth:`get_fetch_users_ips_result` to retrieve the actual list once
the job is complete.
"""
...
@get("/ip-control/fetch-users-ips/result/{jobId}", response_class=FetchUsersIpsResultResponseDto)
async def get_fetch_users_ips_result(
self,
jobId: Annotated[str, Path(description="Job ID returned by fetch_users_ips")],
) -> FetchUsersIpsResultResponseDto:
"""Get Users IP List Result by Job ID.
Poll this endpoint after calling :meth:`fetch_users_ips`. When
``is_completed`` is ``True`` the ``result`` field contains per-user
IP lists.
"""
...
@post("/ip-control/drop-connections", response_class=DropConnectionsResponseDto)
async def drop_connections(
self,

View file

@ -0,0 +1,50 @@
from typing import Annotated
from rapid_api_client import Path
from rapid_api_client.annotations import PydanticBody
from remnawave.models import (
GetNodeMetadataResponseDto,
GetUserMetadataResponseDto,
UpsertNodeMetadataRequestBodyDto,
UpsertNodeMetadataResponseDto,
UpsertUserMetadataRequestBodyDto,
UpsertUserMetadataResponseDto,
)
from remnawave.rapid import BaseController, get, put
class MetadataController(BaseController):
@get("/metadata/user/{uuid}", response_class=GetUserMetadataResponseDto)
async def get_user_metadata(
self,
uuid: Annotated[str, Path(description="User UUID")],
) -> GetUserMetadataResponseDto:
"""Get user metadata"""
...
@put("/metadata/user/{uuid}", response_class=UpsertUserMetadataResponseDto)
async def upsert_user_metadata(
self,
uuid: Annotated[str, Path(description="User UUID")],
body: Annotated[UpsertUserMetadataRequestBodyDto, PydanticBody()],
) -> UpsertUserMetadataResponseDto:
"""Update or create User Metadata"""
...
@get("/metadata/node/{uuid}", response_class=GetNodeMetadataResponseDto)
async def get_node_metadata(
self,
uuid: Annotated[str, Path(description="Node UUID")],
) -> GetNodeMetadataResponseDto:
"""Get node metadata"""
...
@put("/metadata/node/{uuid}", response_class=UpsertNodeMetadataResponseDto)
async def upsert_node_metadata(
self,
uuid: Annotated[str, Path(description="Node UUID")],
body: Annotated[UpsertNodeMetadataRequestBodyDto, PydanticBody()],
) -> UpsertNodeMetadataResponseDto:
"""Update or create Node Metadata"""
...

View file

@ -0,0 +1,110 @@
from typing import Annotated, Optional
from rapid_api_client import Path, PydanticBody, Query
from remnawave.models import (
CloneNodePluginRequestDto,
CloneNodePluginResponseDto,
CreateNodePluginRequestDto,
CreateNodePluginResponseDto,
DeleteNodePluginResponseDto,
GetNodePluginResponseDto,
GetNodePluginsResponseDto,
GetTorrentBlockerReportsResponseDto,
GetTorrentBlockerReportsStatsResponseDto,
PluginExecutorRequestDto,
PluginExecutorResponseDto,
ReorderNodePluginsRequestDto,
ReorderNodePluginsResponseDto,
TruncateTorrentBlockerReportsResponseDto,
UpdateNodePluginRequestDto,
UpdateNodePluginResponseDto,
)
from remnawave.rapid import BaseController, delete, get, patch, post
class NodePluginsController(BaseController):
@get("/node-plugins/torrent-blocker", response_class=GetTorrentBlockerReportsResponseDto)
async def get_torrent_blocker_reports(
self,
size: Annotated[Optional[int], Query(default=None, ge=1, description="Page size")] = None,
start: Annotated[Optional[int], Query(default=None, ge=0, description="Offset")] = None,
) -> GetTorrentBlockerReportsResponseDto:
"""Get Torrent Blocker Reports"""
...
@get("/node-plugins/torrent-blocker/stats", response_class=GetTorrentBlockerReportsStatsResponseDto)
async def get_torrent_blocker_reports_stats(
self,
) -> GetTorrentBlockerReportsStatsResponseDto:
"""Get Torrent Blocker Reports Stats"""
...
@delete("/node-plugins/torrent-blocker/truncate", response_class=TruncateTorrentBlockerReportsResponseDto)
async def truncate_torrent_blocker_reports(
self,
) -> TruncateTorrentBlockerReportsResponseDto:
"""Truncate Torrent Blocker Reports"""
...
@get("/node-plugins", response_class=GetNodePluginsResponseDto)
async def get_all_node_plugins(self) -> GetNodePluginsResponseDto:
"""Get all Node Plugins"""
...
@patch("/node-plugins", response_class=UpdateNodePluginResponseDto)
async def update_node_plugin(
self,
body: Annotated[UpdateNodePluginRequestDto, PydanticBody()],
) -> UpdateNodePluginResponseDto:
"""Update Node Plugin"""
...
@post("/node-plugins", response_class=CreateNodePluginResponseDto)
async def create_node_plugin(
self,
body: Annotated[CreateNodePluginRequestDto, PydanticBody()],
) -> CreateNodePluginResponseDto:
"""Create Node Plugin"""
...
@get("/node-plugins/{uuid}", response_class=GetNodePluginResponseDto)
async def get_node_plugin_by_uuid(
self,
uuid: Annotated[str, Path(description="Node plugin UUID")],
) -> GetNodePluginResponseDto:
"""Get Node Plugin by uuid"""
...
@delete("/node-plugins/{uuid}", response_class=DeleteNodePluginResponseDto)
async def delete_node_plugin(
self,
uuid: Annotated[str, Path(description="Node plugin UUID")],
) -> DeleteNodePluginResponseDto:
"""Delete Node Plugin"""
...
@post("/node-plugins/actions/reorder", response_class=ReorderNodePluginsResponseDto)
async def reorder_node_plugins(
self,
body: Annotated[ReorderNodePluginsRequestDto, PydanticBody()],
) -> ReorderNodePluginsResponseDto:
"""Reorder Node Plugins"""
...
@post("/node-plugins/actions/clone", response_class=CloneNodePluginResponseDto)
async def clone_node_plugin(
self,
body: Annotated[CloneNodePluginRequestDto, PydanticBody()],
) -> CloneNodePluginResponseDto:
"""Clone Node Plugin"""
...
@post("/node-plugins/executor", response_class=PluginExecutorResponseDto)
async def plugin_executor(
self,
body: Annotated[PluginExecutorRequestDto, PydanticBody()],
) -> PluginExecutorResponseDto:
"""Execute command on node plugins"""
...

View file

@ -25,6 +25,8 @@ from remnawave.models import (
ProfileModificationResponseDto,
NodesBulkActionsRequestDto,
NodesBulkActionsResponseDto,
BulkNodesUpdateRequestDto,
BulkNodesUpdateResponseDto,
)
from remnawave.rapid import BaseController, delete, get, patch, post
@ -146,4 +148,12 @@ class NodesController(BaseController):
body: Annotated[NodesBulkActionsRequestDto, PydanticBody()],
) -> NodesBulkActionsResponseDto:
"""Perform actions for many nodes (ENABLE, DISABLE, RESTART, RESET_TRAFFIC)"""
...
@post("/nodes/bulk-actions/update", response_class=BulkNodesUpdateResponseDto)
async def bulk_nodes_update(
self,
body: Annotated[BulkNodesUpdateRequestDto, PydanticBody()],
) -> BulkNodesUpdateResponseDto:
"""Update many nodes"""
...

View file

@ -9,35 +9,35 @@ from remnawave.rapid import BaseController, get
class SubscriptionController(BaseController):
# Public endpoints below
@get("/sub/{short_uuid}/info", response_class=GetSubscriptionInfoResponseDto)
@get("/sub/{shortUuid}/info", response_class=GetSubscriptionInfoResponseDto)
async def get_subscription_info_by_short_uuid(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the user")],
short_uuid: Annotated[str, Path(description="Short UUID of the user", alias="shortUuid")],
) -> GetSubscriptionInfoResponseDto:
"""None"""
...
@get("/sub/{short_uuid}", response_class=str)
@get("/sub/{shortUuid}", response_class=str)
async def get_subscription(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the user")],
short_uuid: Annotated[str, Path(description="Short UUID of the user", alias="shortUuid")],
) -> str:
"""None"""
...
@get("/sub/{short_uuid}/{client_type}", response_class=str)
@get("/sub/{shortUuid}/{clientType}", response_class=str)
async def get_subscription_by_client_type(
self,
client_type: Annotated[ClientType, Path(description="Client type")],
short_uuid: Annotated[str, Path(description="Short UUID of the user")],
client_type: Annotated[ClientType, Path(description="Client type", alias="clientType")],
short_uuid: Annotated[str, Path(description="Short UUID of the user", alias="shortUuid")],
) -> str:
"""None"""
...
@get("/sub/outline/{short_uuid}/{type}/{encoded_tag}", response_class=str)
@get("/sub/outline/{shortUuid}/{type}/{encodedTag}", response_class=str)
async def get_subscription_with_type(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the user")],
short_uuid: Annotated[str, Path(description="Short UUID of the user", alias="shortUuid")],
type: Annotated[
str,
Path(
@ -47,7 +47,8 @@ class SubscriptionController(BaseController):
encoded_tag: Annotated[
str,
Path(
description="Base64 encoded tag for Outline config. This paramter is optional. It is required only when type=ss."
description="Base64 encoded tag for Outline config. This paramter is optional. It is required only when type=ss.",
alias="encodedTag",
),
] = "VGVzdGVy",
) -> str:

View file

@ -3,7 +3,6 @@ from typing import Annotated
from rapid_api_client import Path, Query
from rapid_api_client.annotations import PydanticBody
from remnawave.enums import ClientType
from remnawave.models.subscription import GetRawSubscriptionByShortUuidResponseDto
from remnawave.rapid import BaseController, get
from remnawave.models import (
@ -13,6 +12,7 @@ from remnawave.models import (
GetSubscriptionByUUIDResponseDto,
GetSubpageConfigByShortUuidRequestBodyDto,
GetSubpageConfigByShortUuidResponseDto,
GetConnectionKeysByUuidResponseDto,
)
@ -39,10 +39,10 @@ class SubscriptionsController(BaseController):
"""None"""
...
@get("/subscriptions/by-short-uuid/{short_uuid}", response_class=GetSubscriptionByShortUUIDResponseDto)
@get("/subscriptions/by-short-uuid/{shortUuid}", response_class=GetSubscriptionByShortUUIDResponseDto)
async def get_subscription_by_short_uuid(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the subscription")],
short_uuid: Annotated[str, Path(description="Short UUID of the subscription", alias="shortUuid")],
) -> GetSubscriptionByShortUUIDResponseDto:
"""None"""
...
@ -55,20 +55,28 @@ class SubscriptionsController(BaseController):
"""None"""
...
@get("/subscriptions/subpage-config/{short_uuid}", response_class=GetSubpageConfigByShortUuidResponseDto)
@get("/subscriptions/subpage-config/{shortUuid}", response_class=GetSubpageConfigByShortUuidResponseDto)
async def get_subpage_config(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the subscription")],
short_uuid: Annotated[str, Path(description="Short UUID of the subscription", alias="shortUuid")],
body: Annotated[GetSubpageConfigByShortUuidRequestBodyDto, PydanticBody()],
) -> GetSubpageConfigByShortUuidResponseDto:
"""Get subscription page config by short UUID"""
...
@get("/subscriptions/by-short-uuid/{short_uuid}/raw", response_class=GetRawSubscriptionByShortUuidResponseDto)
@get("/subscriptions/by-short-uuid/{shortUuid}/raw", response_class=GetRawSubscriptionByShortUuidResponseDto)
async def get_raw_subscription(
self,
short_uuid: Annotated[str, Path(description="Short UUID of the user")],
withDisabledHosts: Annotated[Annotated[bool, Path(description="Include disabled hosts")], bool] = False,
short_uuid: Annotated[str, Path(description="Short UUID of the user", alias="shortUuid")],
with_disabled_hosts: Annotated[bool, Query(default=False, alias="withDisabledHosts", description="Include disabled hosts")] = False,
) -> GetRawSubscriptionByShortUuidResponseDto:
"""None"""
...
@get("/subscriptions/connection-keys/{uuid}", response_class=GetConnectionKeysByUuidResponseDto)
async def get_connection_keys_by_uuid(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> GetConnectionKeysByUuidResponseDto:
"""Get connection keys (base64 format) by uuid"""
...

View file

@ -11,7 +11,8 @@ from remnawave.models import (
EncryptHappCryptoLinkResponseDto,
DebugSrrMatcherRequestDto,
DebugSrrMatcherResponseDto,
GetMetadataResponseDto
GetMetadataResponseDto,
GetRecapResponseDto,
)
from remnawave.rapid import BaseController, get, post
@ -80,4 +81,11 @@ class SystemController(BaseController):
body: Annotated[DebugSrrMatcherRequestDto, PydanticBody()],
) -> DebugSrrMatcherResponseDto:
"""Test SRR Matcher"""
...
@get("/system/stats/recap", response_class=GetRecapResponseDto)
async def get_recap(
self,
) -> GetRecapResponseDto:
"""Get Recap"""
...

View file

@ -21,6 +21,12 @@ from remnawave.models import (
UpdateUserRequestDto,
UpdateUserResponseDto,
RevokeUserRequestDto,
RevokeUserSubscriptionResponseDto,
DisableUserResponseDto,
EnableUserResponseDto,
ResetUserTrafficResponseDto,
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
)
from remnawave.rapid import BaseController, delete, get, patch, post
@ -65,36 +71,36 @@ class UsersController(BaseController):
"""Delete user"""
...
@post("/users/{uuid}/actions/revoke", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/revoke", response_class=RevokeUserSubscriptionResponseDto)
async def revoke_user_subscription(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
body: Optional[Annotated[RevokeUserRequestDto, PydanticBody()]] = None,
) -> UpdateUserResponseDto:
) -> RevokeUserSubscriptionResponseDto:
"""Revoke User Subscription"""
...
@post("/users/{uuid}/actions/disable", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/disable", response_class=DisableUserResponseDto)
async def disable_user(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> DisableUserResponseDto:
"""Disable User"""
...
@post("/users/{uuid}/actions/enable", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/enable", response_class=EnableUserResponseDto)
async def enable_user(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> EnableUserResponseDto:
"""Enable User"""
...
@post("/users/{uuid}/actions/reset-traffic", response_class=UpdateUserResponseDto)
@post("/users/{uuid}/actions/reset-traffic", response_class=ResetUserTrafficResponseDto)
async def reset_user_traffic(
self,
uuid: Annotated[str, Path(description="UUID of the user")],
) -> UpdateUserResponseDto:
) -> ResetUserTrafficResponseDto:
"""Reset User Traffic"""
...
@ -186,4 +192,12 @@ class UsersController(BaseController):
tag: Annotated[str, Path(description="Tag of the user")],
) -> TagUserResponseDto:
"""Get Users By Tag"""
...
@post("/users/resolve", response_class=ResolveUserResponseDto)
async def resolve_user(
self,
body: Annotated[ResolveUserRequestBodyDto, PydanticBody()],
) -> ResolveUserResponseDto:
"""Resolve user by any identifier (uuid, id, shortUuid, username)"""
...

View file

@ -6,7 +6,7 @@ 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
TCRMEvents, TErrorsEvents, TNodeEvents, TResetPeriods, TServiceEvents, TUserEvents, TUserHwidDevicesEvents, TUsersStatus, TTorrentBlockerEvents
)
from .auth import OAuth2Provider
from .subscriptions_settings import (
@ -41,4 +41,5 @@ __all__ = [
"TUserHwidDevicesEvents",
"TResetPeriods",
"TUsersStatus",
"TTorrentBlockerEvents",
]

View file

@ -2,7 +2,9 @@ from enum import StrEnum
class OAuth2Provider(StrEnum):
"""OAuth2 Provider enum"""
TELEGRAM = "telegram"
GITHUB = "github"
POCKETID = "pocketid"
YANDEX = "yandex"
KEYCLOAK = "keycloak"
KEYCLOAK = "keycloak"
GENERIC = "generic"

View file

@ -7,4 +7,5 @@ class TemplateType(StrEnum):
SINGBOX_LEGACY = "SINGBOX_LEGACY"
MIHOMO = "MIHOMO"
XRAY_JSON = "XRAY_JSON"
XRAY_BASE64 = "XRAY_BASE64"
CLASH = "CLASH"

View file

@ -13,3 +13,4 @@ class TrafficLimitStrategy(StrEnum):
DAY = "DAY"
WEEK = "WEEK"
MONTH = "MONTH"
MONTH_ROLLING = "MONTH_ROLLING"

View file

@ -57,5 +57,9 @@ TUserHwidDevicesEvents = Literal[
"user_hwid_devices.deleted",
]
TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH"]
TTorrentBlockerEvents = Literal[
"torrent_blocker.report",
]
TResetPeriods = Literal["NO_RESET", "DAY", "WEEK", "MONTH", "MONTH_ROLLING"]
TUsersStatus = Literal["DISABLED", "LIMITED", "EXPIRED", "ACTIVE"]

View file

@ -206,6 +206,8 @@ from .nodes import (
NodeBulkActionType,
NodesBulkActionsRequestDto,
NodesBulkActionsResponseDto,
BulkNodesUpdateRequestDto,
BulkNodesUpdateResponseDto,
)
from .nodes_usage_history import (
GetNodeUserUsageByRangeResponseDto,
@ -225,6 +227,7 @@ from .subscription import (
RawSettings,
GetSubscriptionByShortUUIDResponseDto,
GetSubscriptionByUUIDResponseDto,
GetConnectionKeysByUuidResponseDto,
)
from .subscriptions_settings import (
GetSubscriptionSettingsResponseDto,
@ -233,6 +236,7 @@ from .subscriptions_settings import (
ResponseRule,
ResponseRuleCondition,
ResponseRules,
ResponseRulesSettings,
SubscriptionSettingsResponseDto,
SubscriptionType,
UpdateSubscriptionSettingsRequestDto,
@ -273,19 +277,24 @@ from .system import (
StatusCounts,
UsersStatistic,
GetNodesMetricsResponseDto,
GetX25519KeyPairResponseDto,
GetX25519KeyPairResponseDto,
X25519KeyPair,
DebugSrrMatcherRequestDto,
DebugSrrMatcherResponseDto,
EncryptHappCryptoLinkRequestDto,
EncryptHappCryptoLinkResponseDto,
GetMetadataResponseDto
GetMetadataResponseDto,
GetRecapResponseDto,
RecapThisMonth,
RecapTotal,
)
from .users import (
# Request DTOs
CreateUserRequestDto,
UpdateUserRequestDto,
RevokeUserRequestDto,
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
# Response DTOs - Single User
CreateUserResponseDto,
@ -375,7 +384,7 @@ from .subscription_request_history import (
SubscriptionRequestHistoryStatsData
)
from .webhook import (
UserEventDto,
UserEventDto,
UserHwidDeviceEventDto,
HwidUserDeviceDto,
LastConnectedNodeDto,
@ -391,8 +400,15 @@ from .webhook import (
NodeEventDto,
CustomErrorEventDto,
CrmEventDto,
TorrentBlockerEventDto,
TorrentBlockerReportDto,
WebhookPayloadDto,
UserTrafficDto
UserTrafficDto,
NodeSystemDto,
NodeSystemInfoDto,
NodeSystemStatsDto,
NodeSystemInterfaceDto,
NodeVersionsDto,
)
from .passkeys import (
DeletePasskeyRequestDto,
@ -405,6 +421,42 @@ from .passkeys import (
VerifyPasskeyRegistrationRequestDto,
VerifyPasskeyRegistrationResponseDto,
)
from .metadata import (
GetMetadataResponseDto,
GetUserMetadataResponseDto,
UpsertUserMetadataRequestBodyDto,
UpsertUserMetadataResponseDto,
GetNodeMetadataResponseDto,
UpsertNodeMetadataRequestBodyDto,
UpsertNodeMetadataResponseDto,
)
from .node_plugins import (
GetTorrentBlockerReportsResponseDto,
GetTorrentBlockerReportsStatsResponseDto,
TruncateTorrentBlockerReportsResponseDto,
GetNodePluginsResponseDto,
GetNodePluginResponseDto,
UpdateNodePluginRequestDto,
UpdateNodePluginResponseDto,
DeleteNodePluginResponseDto,
CreateNodePluginRequestDto,
CreateNodePluginResponseDto,
ReorderNodePluginItem,
ReorderNodePluginsRequestDto,
ReorderNodePluginsResponseDto,
CloneNodePluginRequestDto,
CloneNodePluginResponseDto,
PluginExecutorRequestDto,
PluginExecutorResponseDto,
BlockIpsCommandDto,
UnblockIpsCommandDto,
RecreateTablesCommandDto,
BlockIpItemDto,
TorrentBlockerReportRecordDto,
NodePluginDto,
TargetAllNodesDto,
TargetSpecificNodesDto,
)
from .external_squads import (
AddUsersToExternalSquadResponseDto,
CreateExternalSquadRequestDto,
@ -479,6 +531,8 @@ from .ip_control import (
# Response DTOs
FetchIpsResponseDto,
FetchIpsResultResponseDto,
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
DropConnectionsResponseDto,
# Data models
FetchIpsJobData,
@ -486,6 +540,11 @@ from .ip_control import (
FetchIpsNodeResult,
FetchIpsResult,
FetchIpsResultData,
FetchUsersIpsJobData,
FetchUsersIpsUserIp,
FetchUsersIpsUser,
FetchUsersIpsResult,
FetchUsersIpsResultData,
DropConnectionsResponseData,
)
@ -507,6 +566,7 @@ __all__ = [
"VerifyPasskeyAuthenticationRequestDto",
"VerifyPasskeyAuthenticationResponseDto",
"GetPasskeyAuthenticationOptionsResponseDto",
"BrandingSettings",
# Nodes models
"CreateNodeRequestDto",
"CreateNodeResponseDto",
@ -536,6 +596,8 @@ __all__ = [
"NodeBulkActionType",
"NodesBulkActionsRequestDto",
"NodesBulkActionsResponseDto",
"BulkNodesUpdateRequestDto",
"BulkNodesUpdateResponseDto",
# Hosts models
"CreateHostRequestDto",
"CreateHostResponseDto",
@ -576,6 +638,7 @@ __all__ = [
"UserSubscription",
"GetRawSubscriptionByShortUuidResponseDto",
"RawSettings",
"GetConnectionKeysByUuidResponseDto",
# Subscription settings models
"GetSubscriptionSettingsResponseDto",
"SubscriptionSettingsResponseDto",
@ -592,6 +655,7 @@ __all__ = [
"ResponseRule",
"ResponseRuleCondition",
"ResponseRules",
"ResponseRulesSettings",
# Subscription template models
"GetTemplateResponseDto",
"TemplateResponseDto",
@ -627,7 +691,10 @@ __all__ = [
"DebugSrrMatcherResponseDto",
"EncryptHappCryptoLinkRequestDto",
"EncryptHappCryptoLinkResponseDto",
"GetMetadataResponseDto"
"GetMetadataResponseDto",
"GetRecapResponseDto",
"RecapThisMonth",
"RecapTotal",
# XRay config models
"ConfigResponseDto", # Legacy alias
"GetConfigResponseDto",
@ -694,6 +761,8 @@ __all__ = [
"CreateUserRequestDto",
"UpdateUserRequestDto",
"RevokeUserRequestDto",
"ResolveUserRequestBodyDto",
"ResolveUserResponseDto",
"CreateUserResponseDto",
"UpdateUserResponseDto",
"GetUserByUuidResponseDto",
@ -860,6 +929,17 @@ __all__ = [
# CRM EVENTS
"CrmEventDto",
# TORRENT BLOCKER EVENTS
"TorrentBlockerEventDto",
"TorrentBlockerReportDto",
# NODE SYSTEM/VERSIONS
"NodeSystemDto",
"NodeSystemInfoDto",
"NodeSystemStatsDto",
"NodeSystemInterfaceDto",
"NodeVersionsDto",
# WEBHOOK PAYLOAD
"WebhookPayloadDto",
@ -954,5 +1034,48 @@ __all__ = [
"FetchIpsNodeResult",
"FetchIpsResult",
"FetchIpsResultData",
"FetchUsersIpsResponseDto",
"FetchUsersIpsResultResponseDto",
"FetchUsersIpsJobData",
"FetchUsersIpsUserIp",
"FetchUsersIpsUser",
"FetchUsersIpsResult",
"FetchUsersIpsResultData",
"DropConnectionsResponseData",
# Metadata models
"GetMetadataResponseDto",
"GetUserMetadataResponseDto",
"UpsertUserMetadataRequestBodyDto",
"UpsertUserMetadataResponseDto",
"GetNodeMetadataResponseDto",
"UpsertNodeMetadataRequestBodyDto",
"UpsertNodeMetadataResponseDto",
# Node plugins models
"GetTorrentBlockerReportsResponseDto",
"GetTorrentBlockerReportsStatsResponseDto",
"TruncateTorrentBlockerReportsResponseDto",
"GetNodePluginsResponseDto",
"GetNodePluginResponseDto",
"UpdateNodePluginRequestDto",
"UpdateNodePluginResponseDto",
"DeleteNodePluginResponseDto",
"CreateNodePluginRequestDto",
"CreateNodePluginResponseDto",
"ReorderNodePluginItem",
"ReorderNodePluginsRequestDto",
"ReorderNodePluginsResponseDto",
"CloneNodePluginRequestDto",
"CloneNodePluginResponseDto",
"PluginExecutorRequestDto",
"PluginExecutorResponseDto",
"BlockIpsCommandDto",
"UnblockIpsCommandDto",
"RecreateTablesCommandDto",
"BlockIpItemDto",
"TorrentBlockerReportRecordDto",
"NodePluginDto",
"TargetAllNodesDto",
"TargetSpecificNodesDto",
]

View file

@ -1,6 +1,6 @@
from typing import Annotated, Any, Dict, Optional
from pydantic import BaseModel, Field, StringConstraints
from pydantic import BaseModel, Field, StringConstraints, field_validator
from remnawave.enums.auth import OAuth2Provider
@ -17,18 +17,35 @@ class LoginResponseDto(BaseModel):
access_token: str = Field(alias="accessToken")
class TelegramBotInfo(BaseModel):
bot_id: int = Field(alias="botId")
class PasskeyAuthenticationSettings(BaseModel):
enabled: bool
class StatusResponseData(BaseModel):
class OAuth2ProvidersSettings(BaseModel):
providers: Dict[str, bool]
class PasswordAuthenticationSettings(BaseModel):
enabled: bool
class AuthenticationSettings(BaseModel):
passkey: PasskeyAuthenticationSettings
oauth2: OAuth2ProvidersSettings
password: PasswordAuthenticationSettings
class BrandingSettings(BaseModel):
title: Optional[str] = None
logo_url: Optional[str] = Field(None, alias="logoUrl")
class GetStatusResponseDto(BaseModel):
"""Status response with authentication and branding settings"""
is_login_allowed: bool = Field(alias="isLoginAllowed")
is_register_allowed: bool = Field(alias="isRegisterAllowed")
tg_auth: Optional[TelegramBotInfo] = Field(None, alias="tgAuth")
class GetStatusResponseDto(StatusResponseData):
pass
authentication: Optional[AuthenticationSettings] = None
branding: BrandingSettings
class LoginRequestDto(BaseModel):
@ -40,6 +57,17 @@ class RegisterRequestDto(BaseModel):
username: str
password: Annotated[str, StringConstraints(min_length=24)]
@field_validator("password")
@classmethod
def validate_password_complexity(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
class TelegramCallbackRequestDto(BaseModel):
id: int
@ -82,8 +110,8 @@ class OAuth2CallbackResponseDto(BaseModel):
# Passkey Authentication models
class GetPasskeyAuthenticationOptionsResponseDto(BaseModel):
"""Response with passkey authentication options"""
# Passkey options are complex WebAuthn objects, using Any for flexibility
response: Dict[str, Any]
# Passkey options are complex WebAuthn objects
pass
class VerifyPasskeyAuthenticationRequestDto(BaseModel):

View file

@ -1,8 +1,8 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Annotated, Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
class InboundDto(BaseModel):
@ -32,7 +32,7 @@ class ConfigProfileDto(BaseModel):
class CreateConfigProfileRequestDto(BaseModel):
name: str
name: Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]
config: Dict[str, Any]
@ -42,7 +42,7 @@ class CreateConfigProfileResponseDto(ConfigProfileDto):
class UpdateConfigProfileRequestDto(BaseModel):
uuid: UUID
name: Optional[str] = Field(None, pattern=r"^[A-Za-z0-9_-]+$")
name: Optional[Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]] = None
config: Optional[Dict[str, Any]] = None
@ -51,7 +51,7 @@ class UpdateConfigProfileResponseDto(ConfigProfileDto):
class GetAllConfigProfilesResponsePaginated(BaseModel):
total: int
total: float
config_profiles: List[ConfigProfileDto] = Field(alias="configProfiles")

View file

@ -58,6 +58,7 @@ class ExternalSquadDto(BaseModel):
response_headers: Optional[Dict[str, str]] = Field(None, alias="responseHeaders")
hwid_settings: Optional[HwidSettingsDto] = Field(None, alias="hwidSettings")
custom_remarks: Optional[CustomRemarksDto] = Field(None, alias="customRemarks")
subpage_config_uuid: Optional[UUID] = Field(None, alias="subpageConfigUuid")
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
@ -65,7 +66,7 @@ class ExternalSquadDto(BaseModel):
# Request/Response models
class GetExternalSquadsResponseDto(BaseModel):
"""Response with all external squads"""
total: int = Field(alias="total")
total: float = Field(alias="total")
external_squads: List[ExternalSquadDto] = Field(alias="externalSquads")
@ -94,6 +95,7 @@ class UpdateExternalSquadRequestDto(BaseModel):
hwid_settings: Optional[HwidSettingsDto] = Field(None, alias="hwidSettings")
custom_remarks: Optional[CustomRemarksDto] = Field(None, alias="customRemarks")
response_headers: Optional[Dict[str, str]] = Field(None, serialization_alias="responseHeaders")
subpage_config_uuid: Optional[UUID] = Field(None, serialization_alias="subpageConfigUuid")
class UpdateExternalSquadResponseDto(ExternalSquadDto):
@ -117,15 +119,10 @@ class ReorderExternalSquadsRequestDto(BaseModel):
class ReorderExternalSquadsResponseDto(BaseModel):
"""Response after reordering external squads"""
total: int = Field(alias="total")
total: float = Field(alias="total")
external_squads: List[ExternalSquadDto] = Field(alias="externalSquads")
class DeleteExternalSquadResponseDto(BaseModel):
"""Response after deleting external squad"""
is_deleted: bool = Field(alias="isDeleted")
class AddUsersToExternalSquadResponseDto(BaseModel):
"""Response after adding users to external squad"""
event_sent: bool = Field(alias="eventSent")

View file

@ -43,7 +43,7 @@ class UpdateHostRequestDto(BaseModel):
tag: Optional[Annotated[str, StringConstraints(max_length=32, pattern=r"^[A-Z0-9_:]+$")]] = None
is_hidden: Optional[bool] = Field(None, serialization_alias="isHidden")
override_sni_from_address: Optional[bool] = Field(None, serialization_alias="overrideSniFromAddress")
keep_blank_sni: Optional[bool] = Field(None, serialization_alias="keepBlankSni")
keep_blank_sni: Optional[bool] = Field(None, serialization_alias="keepSniBlank")
vless_route_id: Optional[int] = Field(None, serialization_alias="vlessRouteId", ge=0, le=65535)
shuffle_host: Optional[bool] = Field(None, serialization_alias="shuffleHost")
mihomo_x25519: Optional[bool] = Field(None, serialization_alias="mihomoX25519")
@ -89,7 +89,7 @@ class HostResponseDto(BaseModel):
security_layer: SecurityLayer = Field(SecurityLayer.DEFAULT, alias="securityLayer")
is_hidden: bool = Field(False, alias="isHidden")
override_sni_from_address: bool = Field(False, alias="overrideSniFromAddress")
keep_blank_sni: bool = Field(False, alias="keepBlankSni")
keep_blank_sni: bool = Field(False, alias="keepSniBlank")
allow_insecure: bool = Field(False, alias="allowInsecure")
xray_json_template_uuid: UUID | None = Field(alias="xrayJsonTemplateUuid")
excluded_internal_squads: List[UUID] = Field(default_factory=list, alias="excludedInternalSquads")
@ -128,7 +128,7 @@ class CreateHostRequestDto(BaseModel):
security_layer: SecurityLayer = Field(SecurityLayer.DEFAULT, serialization_alias="securityLayer")
is_hidden: bool = Field(False, serialization_alias="isHidden")
override_sni_from_address: bool = Field(False, serialization_alias="overrideSniFromAddress")
keep_blank_sni: bool = Field(False, serialization_alias="keepBlankSni")
keep_blank_sni: bool = Field(False, serialization_alias="keepSniBlank")
xray_json_template_uuid: Optional[UUID] = Field(None, serialization_alias="xrayJsonTemplateUuid")
excluded_internal_squads: List[UUID] = Field(default_factory=list, serialization_alias="excludedInternalSquads")
exclude_from_subscription_types: List[SubscriptionType] = Field(
@ -147,6 +147,10 @@ class CreateHostRequestDto(BaseModel):
config_profile_uuid: Optional[UUID] = None,
**data,
):
# Backward-compatible support for misspelled helper argument used in old tests/examples
if config_profile_uuid is None and "config_profile_inbound_uuid" in data:
config_profile_uuid = data.pop("config_profile_inbound_uuid")
if inbound_uuid is not None and "inbound" not in data:
data["inbound"] = CreateHostInboundData(
config_profile_uuid=config_profile_uuid

View file

@ -31,22 +31,22 @@ class HwidDeviceDto(BaseModel):
class HwidDevicesData(BaseModel):
total: int
total: float
devices: List[HwidDeviceDto]
class CreateUserHwidDeviceResponseDto(BaseModel):
total: int
total: float
devices: List[HwidDeviceDto]
class DeleteUserHwidDeviceResponseDto(BaseModel):
total: int
total: float
devices: List[HwidDeviceDto]
class GetUserHwidDevicesResponseDto(BaseModel):
total: int
total: float
devices: List[HwidDeviceDto]
class PlatformStatItem(BaseModel):
@ -82,13 +82,13 @@ class TopUserByHwidDevicesDto(BaseModel):
user_uuid: UUID = Field(alias="userUuid")
id: int
username: str
devices_count: int = Field(alias="devicesCount")
devices_count: float = Field(alias="devicesCount")
class TopUsersByHwidDevicesData(BaseModel):
"""Top users by HWID devices data"""
users: list[TopUserByHwidDevicesDto]
total: int
total: float
class GetTopUsersByHwidDevicesResponseDto(TopUsersByHwidDevicesData):

View file

@ -13,11 +13,11 @@ class InboundResponseDto(BaseModel):
security: Optional[str] = None
port: Optional[float] = None
raw_inbound: Optional[Any] = Field(None, alias="rawInbound")
active_squads: Optional[list[UUID]] = Field(None, alias="activeSquads")
active_squads: List[UUID] = Field(default_factory=list, alias="activeSquads")
class AllInboundsData(BaseModel):
total: int
total: float
inbounds: List[InboundResponseDto]
@ -26,7 +26,7 @@ class GetAllInboundsResponseDto(AllInboundsData):
class InboundsByProfileData(BaseModel):
total: int
total: float
inbounds: List[InboundResponseDto]

View file

@ -102,7 +102,7 @@ class UpdateInfraProviderResponseDto(InfraProviderDto):
class AllInfraProvidersData(BaseModel):
total: int = Field(alias="total")
total: float = Field(alias="total")
providers: List[InfraProviderDto]
@ -122,11 +122,9 @@ class DeleteInfraProviderByUuidResponseDto(BaseModel):
# Billing History models
class CreateInfraBillingHistoryRecordRequestDto(BaseModel):
"""Модель для создания записи истории биллинга"""
node_uuid: UUID = Field(serialization_alias="nodeUuid")
provider_uuid: UUID = Field(serialization_alias="providerUuid")
amount: float
description: Optional[str] = None
payment_date: datetime = Field(serialization_alias="paymentDate")
amount: float = Field(ge=0)
billed_at: datetime = Field(serialization_alias="billedAt")
class CreateInfraBillingHistoryRecordResponseDto(InfraBillingHistoryDto):
@ -135,7 +133,7 @@ class CreateInfraBillingHistoryRecordResponseDto(InfraBillingHistoryDto):
class InfraBillingHistoryData(BaseModel):
records: List[InfraBillingHistoryDto]
total: int
total: float
class GetInfraBillingHistoryRecordsResponseDto(InfraBillingHistoryData):
@ -150,7 +148,7 @@ class DeleteInfraBillingHistoryRecordByUuidResponseDto(BaseModel):
class CreateInfraBillingNodeRequestDto(BaseModel):
node_uuid: UUID = Field(serialization_alias="nodeUuid")
provider_uuid: UUID = Field(serialization_alias="providerUuid")
next_billing_at: datetime = Field(serialization_alias="nextBillingAt")
next_billing_at: Optional[datetime] = Field(None, serialization_alias="nextBillingAt")
# ИСПРАВЛЕНО: API возвращает список всех billing nodes после создания, а не один созданный
@ -167,8 +165,12 @@ class UpdateInfraBillingNodeRequestDto(BaseModel):
next_billing_at: datetime = Field(serialization_alias="nextBillingAt")
class UpdateInfraBillingNodeResponseDto(InfraBillingNodeDto):
pass
class UpdateInfraBillingNodeResponseDto(BaseModel):
total_billing_nodes: float = Field(alias="totalBillingNodes")
billing_nodes: List[InfraBillingNodeDto] = Field(alias="billingNodes")
available_billing_nodes: List[AvailableBillingNodeDto] = Field(alias="availableBillingNodes")
total_available_billing_nodes: float = Field(alias="totalAvailableBillingNodes")
stats: BillingStatsDto
class InfraBillingNodesData(BaseModel):

View file

@ -1,8 +1,8 @@
from datetime import datetime
from typing import List, Optional
from typing import Annotated, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
class InboundsDto(BaseModel):
@ -17,8 +17,8 @@ class InboundsDto(BaseModel):
class InfoDto(BaseModel):
members_count: int = Field(alias="membersCount")
inbounds_count: int = Field(alias="inboundsCount")
members_count: float = Field(alias="membersCount")
inbounds_count: float = Field(alias="inboundsCount")
class InternalSquadDto(BaseModel):
@ -32,7 +32,7 @@ class InternalSquadDto(BaseModel):
class CreateInternalSquadRequestDto(BaseModel):
name: str
name: Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]
inbounds: List[UUID] = Field(default_factory=list)
@ -43,7 +43,7 @@ class CreateInternalSquadResponseDto(InternalSquadDto):
class UpdateInternalSquadRequestDto(BaseModel):
uuid: UUID
inbounds: List[UUID] = Field(default_factory=list)
name: Optional[str] = Field(None, pattern=r"^[A-Za-z0-9_-]+$")
name: Optional[Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]] = None
class UpdateInternalSquadResponseDto(InternalSquadDto):
@ -51,7 +51,7 @@ class UpdateInternalSquadResponseDto(InternalSquadDto):
class GetAllInternalSquadsResponse(BaseModel):
total: int
total: float
internal_squads: List[InternalSquadDto] = Field(alias="internalSquads")

View file

@ -1,3 +1,4 @@
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from uuid import UUID
@ -114,6 +115,55 @@ TargetNodes = Annotated[
]
# ─────────────────────────────────────────────────────────────────────────────
# Fetch Users IPs step 1: start the job
# ─────────────────────────────────────────────────────────────────────────────
class FetchUsersIpsJobData(BaseModel):
"""Returned job ID after requesting users IP fetch"""
job_id: str = Field(alias="jobId")
class FetchUsersIpsResponseDto(FetchUsersIpsJobData):
"""Response for POST /api/ip-control/fetch-users-ips/{nodeUuid}"""
pass
# ─────────────────────────────────────────────────────────────────────────────
# Fetch Users IPs step 2: poll the job result
# ─────────────────────────────────────────────────────────────────────────────
class FetchUsersIpsUserIp(BaseModel):
"""IP entry with last seen timestamp"""
ip: str
last_seen: datetime = Field(alias="lastSeen")
class FetchUsersIpsUser(BaseModel):
"""Per-user IP list"""
user_id: str = Field(alias="userId")
ips: List[FetchUsersIpsUserIp]
class FetchUsersIpsResult(BaseModel):
"""Full result payload when the job is completed"""
success: bool
node_uuid: UUID = Field(alias="nodeUuid")
users: List[FetchUsersIpsUser]
class FetchUsersIpsResultData(BaseModel):
"""Job state + optional result"""
is_completed: bool = Field(alias="isCompleted")
is_failed: bool = Field(alias="isFailed")
result: Optional[FetchUsersIpsResult] = None
class FetchUsersIpsResultResponseDto(FetchUsersIpsResultData):
"""Response for GET /api/ip-control/fetch-users-ips/result/{jobId}"""
pass
class DropConnectionsRequestDto(BaseModel):
"""Request body for POST /api/ip-control/drop-connections"""
drop_by: DropBy = Field(

View file

@ -0,0 +1,41 @@
"""Metadata management models for Users and Nodes"""
from typing import Any, Dict, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class GetMetadataResponseDto(BaseModel):
"""Get metadata response"""
metadata: Optional[Dict[str, Any]] = None
class GetUserMetadataResponseDto(BaseModel):
"""Get user metadata response"""
metadata: Optional[Dict[str, Any]] = None
class UpsertUserMetadataRequestBodyDto(BaseModel):
"""Request body for upserting user metadata"""
metadata: Dict[str, Any]
class UpsertUserMetadataResponseDto(BaseModel):
"""Response for upserting user metadata"""
metadata: Dict[str, Any]
class GetNodeMetadataResponseDto(BaseModel):
"""Get node metadata response"""
metadata: Optional[Dict[str, Any]] = None
class UpsertNodeMetadataRequestBodyDto(BaseModel):
"""Request body for upserting node metadata"""
metadata: Dict[str, Any]
class UpsertNodeMetadataResponseDto(BaseModel):
"""Response for upserting node metadata"""
metadata: Dict[str, Any]

View file

@ -0,0 +1,235 @@
from datetime import datetime
from typing import Any, Annotated, List, Literal, Optional, Union
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
class TorrentBlockerUserDto(BaseModel):
uuid: UUID
username: str
class TorrentBlockerNodeDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
uuid: UUID
name: str
country_code: str = Field(alias="countryCode")
class TorrentBlockerActionReportDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
blocked: bool
ip: str
block_duration: float = Field(alias="blockDuration")
will_unblock_at: datetime = Field(alias="willUnblockAt")
user_id: str = Field(alias="userId")
processed_at: datetime = Field(alias="processedAt")
class TorrentBlockerXrayReportDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
email: Optional[str] = None
level: Optional[float] = None
protocol: Optional[str] = None
network: str
source: Optional[str] = None
destination: str
route_target: Optional[str] = Field(default=None, alias="routeTarget")
original_target: Optional[str] = Field(default=None, alias="originalTarget")
inbound_tag: Optional[str] = Field(default=None, alias="inboundTag")
inbound_name: Optional[str] = Field(default=None, alias="inboundName")
inbound_local: Optional[str] = Field(default=None, alias="inboundLocal")
outbound_tag: Optional[str] = Field(default=None, alias="outboundTag")
ts: float
class TorrentBlockerReportPayloadDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
action_report: TorrentBlockerActionReportDto = Field(alias="actionReport")
xray_report: TorrentBlockerXrayReportDto = Field(alias="xrayReport")
class TorrentBlockerReportRecordDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: float
user_id: float = Field(alias="userId")
node_id: float = Field(alias="nodeId")
user: TorrentBlockerUserDto
node: TorrentBlockerNodeDto
report: TorrentBlockerReportPayloadDto
created_at: datetime = Field(alias="createdAt")
class TorrentBlockerReportsData(BaseModel):
records: List[TorrentBlockerReportRecordDto]
total: float
class GetTorrentBlockerReportsResponseDto(TorrentBlockerReportsData):
pass
class TruncateTorrentBlockerReportsResponseDto(TorrentBlockerReportsData):
pass
class TorrentBlockerStatsDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
distinct_nodes: float = Field(alias="distinctNodes")
distinct_users: float = Field(alias="distinctUsers")
total_reports: float = Field(alias="totalReports")
reports_last_24_hours: float = Field(alias="reportsLast24Hours")
class TorrentBlockerTopUserDto(BaseModel):
uuid: UUID
color: str
username: str
total: float
class TorrentBlockerTopNodeDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
uuid: UUID
country_code: str = Field(alias="countryCode")
color: str
name: str
total: float
class GetTorrentBlockerReportsStatsResponseDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
stats: TorrentBlockerStatsDto
top_users: List[TorrentBlockerTopUserDto] = Field(alias="topUsers")
top_nodes: List[TorrentBlockerTopNodeDto] = Field(alias="topNodes")
class NodePluginDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
uuid: UUID
view_position: int = Field(alias="viewPosition")
name: str
plugin_config: Any | None = Field(alias="pluginConfig")
class GetNodePluginsResponseDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
total: float
node_plugins: List[NodePluginDto] = Field(alias="nodePlugins")
class GetNodePluginResponseDto(NodePluginDto):
pass
class UpdateNodePluginRequestDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
uuid: UUID
name: Optional[
Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]
] = None
plugin_config: Optional[Any] = Field(default=None, alias="pluginConfig")
class UpdateNodePluginResponseDto(NodePluginDto):
pass
class DeleteNodePluginResponseDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
is_deleted: bool = Field(alias="isDeleted")
class CreateNodePluginRequestDto(BaseModel):
name: Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]
class CreateNodePluginResponseDto(NodePluginDto):
pass
class ReorderNodePluginItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
view_position: int = Field(alias="viewPosition")
uuid: UUID
class ReorderNodePluginsRequestDto(BaseModel):
items: List[ReorderNodePluginItem]
class ReorderNodePluginsResponseDto(GetNodePluginsResponseDto):
pass
class CloneNodePluginRequestDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
clone_from_uuid: UUID = Field(alias="cloneFromUuid")
class CloneNodePluginResponseDto(NodePluginDto):
pass
class BlockIpItemDto(BaseModel):
ip: str
timeout: float
class BlockIpsCommandDto(BaseModel):
command: Literal["blockIps"]
ips: List[BlockIpItemDto]
class UnblockIpsCommandDto(BaseModel):
command: Literal["unblockIps"]
ips: List[str]
class RecreateTablesCommandDto(BaseModel):
command: Literal["recreateTables"]
PluginCommandDto = Union[BlockIpsCommandDto, UnblockIpsCommandDto, RecreateTablesCommandDto]
class TargetAllNodesDto(BaseModel):
target: Literal["allNodes"]
class TargetSpecificNodesDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
target: Literal["specificNodes"]
node_uuids: List[UUID] = Field(alias="nodeUuids")
PluginTargetNodesDto = Union[TargetAllNodesDto, TargetSpecificNodesDto]
class PluginExecutorRequestDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
command: PluginCommandDto
target_nodes: PluginTargetNodesDto = Field(alias="targetNodes")
class PluginExecutorResponseDto(BaseModel):
model_config = ConfigDict(populate_by_name=True)
event_sent: bool = Field(alias="eventSent")

View file

@ -89,6 +89,9 @@ class CreateNodeRequestDto(BaseModel):
serialization_alias="tags",
max_length=10
)
active_plugin_uuid: Optional[UUID] = Field(
None, serialization_alias="activePluginUuid"
)
class UpdateNodeRequestDto(BaseModel):
@ -126,6 +129,9 @@ class UpdateNodeRequestDto(BaseModel):
serialization_alias="tags",
max_length=10
)
active_plugin_uuid: Optional[UUID] = Field(
None, serialization_alias="activePluginUuid"
)
class ReorderNodeRequestDto(BaseModel):
@ -144,7 +150,7 @@ class NodeResponseDto(BaseModel):
last_status_message: Optional[str] = Field(None, alias="lastStatusMessage")
xray_version: Optional[str] = Field(None, alias="xrayVersion")
node_version: Optional[str] = Field(None, alias="nodeVersion")
xray_uptime: str = Field(alias="xrayUptime")
xray_uptime: float = Field(0, alias="xrayUptime")
is_traffic_tracking_active: bool = Field(alias="isTrafficTrackingActive")
traffic_reset_day: Optional[int] = Field(None, alias="trafficResetDay")
traffic_limit_bytes: Optional[float] = Field(None, alias="trafficLimitBytes")
@ -163,6 +169,7 @@ class NodeResponseDto(BaseModel):
provider_uuid: Optional[UUID] = Field(None, alias="providerUuid")
provider: Optional[NodeProviderDto] = None
tags: List[str] = Field(default_factory=list, alias="tags")
active_plugin_uuid: Optional[UUID] = Field(None, alias="activePluginUuid")
class CreateNodeResponseDto(NodeResponseDto):
@ -287,4 +294,14 @@ class NodesBulkActionsRequestDto(BaseModel):
class NodesBulkActionsResponseDto(BaseModel):
"""Response after performing bulk actions on nodes"""
event_sent: bool = Field(alias="eventSent")
event_sent: bool = Field(alias="eventSent")
class BulkNodesUpdateRequestDto(NodesBulkActionsRequestDto):
"""OpenAPI alias for bulk nodes update request"""
pass
class BulkNodesUpdateResponseDto(NodesBulkActionsResponseDto):
"""OpenAPI alias for bulk nodes update response"""
pass

View file

@ -1,7 +1,8 @@
from datetime import datetime
from typing import Any, Dict, List
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
from typing import Annotated
class PasskeyDto(BaseModel):
@ -15,8 +16,8 @@ class PasskeyDto(BaseModel):
# Registration models
class GetPasskeyRegistrationOptionsResponseDto(BaseModel):
"""Response with passkey registration options"""
# WebAuthn registration options are complex objects, using Any for flexibility
response: Dict[str, Any]
# WebAuthn registration options are complex objects
pass
class VerifyPasskeyRegistrationRequestDto(BaseModel):
@ -25,12 +26,21 @@ class VerifyPasskeyRegistrationRequestDto(BaseModel):
response: Dict[str, Any]
class VerifyPasskeyRegistrationResponseData(BaseModel):
"""Passkey registration verification result data"""
verified: bool
class VerifyPasskeyRegistrationResponseDto(BaseModel):
"""Response with passkey registration verification result"""
verified: bool
# Passkeys management models
class GetAllPasskeysResponseData(BaseModel):
"""Response data with all user's passkeys"""
passkeys: List[PasskeyDto]
class GetAllPasskeysResponseDto(BaseModel):
"""Response with all user's passkeys"""
passkeys: List[PasskeyDto]
@ -41,6 +51,11 @@ class DeletePasskeyRequestDto(BaseModel):
id: str
class DeletePasskeyResponseData(BaseModel):
"""Response data with updated passkeys list after deletion"""
passkeys: List[PasskeyDto]
class DeletePasskeyResponseDto(BaseModel):
"""Response with updated passkeys list after deletion"""
passkeys: List[PasskeyDto]
@ -49,9 +64,14 @@ class DeletePasskeyResponseDto(BaseModel):
class UpdatePasskeyRequestDto(BaseModel):
"""Request to update a passkey"""
id: str
name: str
name: Annotated[str, StringConstraints(min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$")]
class UpdatePasskeyResponseData(BaseModel):
"""Response data with updated passkeys list"""
passkeys: List[PasskeyDto]
class UpdatePasskeyResponseDto(BaseModel):
"""Response with updated passkey information"""
passkey: PasskeyDto
passkeys: List[PasskeyDto]

View file

@ -58,6 +58,15 @@ class GenericOAuth2Settings(BaseModel):
allowed_emails: List[str] = Field(alias="allowedEmails")
class TelegramOAuth2Settings(BaseModel):
"""Telegram OAuth2 settings"""
enabled: bool
client_id: str | None = Field(alias="clientId")
client_secret: str | None = Field(alias="clientSecret")
allowed_ids: List[str] = Field(alias="allowedIds")
frontend_domain: str | None = Field(alias="frontendDomain")
class OAuth2Settings(BaseModel):
"""OAuth2 authentication settings"""
github: GitHubOAuth2Settings
@ -65,13 +74,16 @@ class OAuth2Settings(BaseModel):
yandex: YandexOAuth2Settings
keycloak: KeycloakOAuth2Settings
generic: GenericOAuth2Settings
telegram: TelegramOAuth2Settings
class TelegramAuthSettings(BaseModel):
"""Telegram authentication settings"""
enabled: bool
bot_token: str | None = Field(alias="botToken")
admin_ids: List[str] = Field(alias="adminIds")
client_id: str | None = Field(alias="clientId")
client_secret: str | None = Field(alias="clientSecret")
allowed_ids: List[str] = Field(alias="allowedIds")
frontend_domain: str | None = Field(alias="frontendDomain")
class PasswordSettings(BaseModel):
@ -89,7 +101,6 @@ class RemnawaveSettingsData(BaseModel):
"""Remnawave settings data"""
passkey_settings: PasskeySettings | None = Field(alias="passkeySettings")
oauth2_settings: OAuth2Settings | None = Field(alias="oauth2Settings")
tg_auth_settings: TelegramAuthSettings | None = Field(alias="tgAuthSettings")
password_settings: Optional[PasswordSettings] = Field(None, alias="passwordSettings")
branding_settings: Optional[BrandingSettings] = Field(None, alias="brandingSettings")
@ -103,7 +114,6 @@ class UpdateRemnawaveSettingsRequestDto(BaseModel):
"""Update Remnawave settings request"""
passkey_settings: Optional[PasskeySettings] = Field(None, serialization_alias="passkeySettings")
oauth2_settings: Optional[OAuth2Settings] = Field(None, serialization_alias="oauth2Settings")
tg_auth_settings: Optional[TelegramAuthSettings] = Field(None, serialization_alias="tgAuthSettings")
password_settings: Optional[PasswordSettings] = Field(None, serialization_alias="passwordSettings")
branding_settings: Optional[BrandingSettings] = Field(None, serialization_alias="brandingSettings")

View file

@ -166,7 +166,7 @@ class RawSubscriptionResponse(BaseModel):
user: UserResponseDto
converted_user_info: ConvertedUserInfo = Field(alias="convertedUserInfo")
headers: Dict[str, str]
raw_hosts: List[RawHost] = Field(alias="rawHosts")
raw_hosts: Optional[List[RawHost]] = Field(None, alias="rawHosts")
class GetRawSubscriptionByShortUuidResponseDto(RawSubscriptionResponse):
@ -176,7 +176,7 @@ class GetRawSubscriptionByShortUuidResponseDto(RawSubscriptionResponse):
class UserSubscription(BaseModel):
short_uuid: str = Field(alias="shortUuid")
username: str
days_left: int = Field(alias="daysLeft")
days_left: float = Field(alias="daysLeft")
traffic_used: str = Field(alias="trafficUsed")
traffic_limit: str = Field(alias="trafficLimit")
lifetime_traffic_used: str = Field(alias="lifetimeTrafficUsed")
@ -222,7 +222,7 @@ class SubscriptionWithoutHapp(BaseModel):
class GetAllSubscriptionsResponseDto(BaseModel):
subscriptions: List[SubscriptionWithoutHapp]
total: int
total: float
class GetSubscriptionByUsernameResponseDto(BaseModel):
@ -241,5 +241,16 @@ class GetSubscriptionByUUIDResponseDto(GetSubscriptionByUsernameResponseDto):
pass
class GetConnectionKeysByUuidResponseDto(BaseModel):
enabled_keys: List[str] = Field(alias="enabledKeys")
hidden_keys: List[str] = Field(alias="hiddenKeys")
disabled_keys: List[str] = Field(alias="disabledKeys")
@property
def connection_keys(self) -> List[str]:
"""Backward compatibility: historically SDK exposed a flat list of keys."""
return self.enabled_keys
# Legacy alias for backward compatibility
SubscriptionInfoResponseDto = GetSubscriptionInfoResponseDto

View file

@ -16,7 +16,7 @@ class SubscriptionPageConfigDto(BaseModel):
class GetSubscriptionPageConfigsData(BaseModel):
"""Data for getting all subscription page configs"""
total: int
total: float
configs: List[SubscriptionPageConfigDto]

View file

@ -74,10 +74,20 @@ class ResponseRule(BaseModel):
)
class ResponseRulesSettings(BaseModel):
"""Settings for response rules"""
model_config = {"populate_by_name": True}
disable_subscription_access_by_path: Optional[bool] = Field(
None, alias="disableSubscriptionAccessByPath"
)
class ResponseRules(BaseModel):
"""Response rules configuration"""
version: ResponseRuleVersion
rules: List[ResponseRule]
settings: Optional[ResponseRulesSettings] = None
class CustomRemarksDto(BaseModel):

View file

@ -29,7 +29,7 @@ class GetTemplateResponseDto(TemplateResponseDto):
pass
class GetTemplatesData(BaseModel):
total: int
total: float
templates: List[TemplateInfoDto]
class GetTemplatesResponseDto(GetTemplatesData):

View file

@ -10,7 +10,7 @@ from remnawave.models.subscriptions_settings import ResponseRule, ResponseRules
class NodeStatistic(BaseModel):
node_name: str = Field(alias="nodeName")
date: datetime.date
total_bytes: int = Field(alias="totalBytes")
total_bytes: str = Field(alias="totalBytes")
class NodesStatisticResponseDto(BaseModel):
@ -32,16 +32,16 @@ class BandwidthStatisticResponseDto(BaseModel):
class CPUStatistic(BaseModel):
cores: int
physical_cores: int = Field(alias="physicalCores")
cores: float
physical_cores: Optional[float] = Field(None, alias="physicalCores")
class MemoryStatistic(BaseModel):
total: int
free: int
used: int
active: int
available: int
total: float
free: float
used: float
active: Optional[float] = None
available: Optional[float] = None
class StatusCounts(BaseModel):
@ -59,18 +59,18 @@ class StatusCounts(BaseModel):
class UsersStatistic(BaseModel):
status_counts: StatusCounts = Field(alias="statusCounts")
total_users: int = Field(alias="totalUsers")
total_users: float = Field(alias="totalUsers")
class OnlineStatistic(BaseModel):
last_day: int = Field(alias="lastDay")
last_week: int = Field(alias="lastWeek")
never_online: int = Field(alias="neverOnline")
online_now: int = Field(alias="onlineNow")
last_day: float = Field(alias="lastDay")
last_week: float = Field(alias="lastWeek")
never_online: float = Field(alias="neverOnline")
online_now: float = Field(alias="onlineNow")
class NodesStatistic(BaseModel):
total_online: int = Field(alias="totalOnline")
total_online: float = Field(alias="totalOnline")
total_bytes_lifetime: str = Field(alias="totalBytesLifetime")
@ -79,7 +79,7 @@ class StatisticResponseDto(BaseModel):
cpu: CPUStatistic
memory: MemoryStatistic
uptime: float
timestamp: int
timestamp: float
users: UsersStatistic
online_stats: OnlineStatistic = Field(alias="onlineStats")
nodes: NodesStatistic
@ -112,25 +112,73 @@ class GetNodesStatisticsResponseDto(BaseModel):
last_seven_days: List[NodeStatistic] = Field(alias="lastSevenDays")
class RuntimeMetric(BaseModel):
"""Runtime metric from health endpoint"""
model_config = {"extra": "allow"}
rss: Optional[float] = None
heap_total: Optional[float] = Field(None, alias="heapTotal")
heap_used: Optional[float] = Field(None, alias="heapUsed")
external: Optional[float] = None
instance_type: Optional[str] = Field(None, alias="instanceType")
class GetRemnawaveHealthResponseDto(BaseModel):
pm2_stats: List[PM2Stat] = Field(alias="pm2Stats")
pm2_stats: Optional[List[PM2Stat]] = Field(None, alias="pm2Stats")
runtime_metrics: Optional[List[RuntimeMetric]] = Field(None, alias="runtimeMetrics")
class TrafficStatDto(BaseModel):
tag: str
upload: str
download: str
class NodeMetric(BaseModel):
"""Node metric data"""
uuid: str = Field(alias="nodeUuid")
name: Optional[str] = None
address: Optional[str] = None
is_online: Optional[bool] = Field(None, alias="isOnline")
cpu_usage: Optional[float] = Field(None, alias="cpuUsage")
memory_usage: Optional[float] = Field(None, alias="memoryUsage")
network_upload: Optional[int] = Field(None, alias="networkUpload")
network_download: Optional[int] = Field(None, alias="networkDownload")
uptime: Optional[int] = None
last_seen: Optional[datetime.datetime] = Field(None, alias="lastSeen")
connected_users: Optional[int] = Field(None, alias="connectedUsers")
upload: Optional[str] = None
download: Optional[str] = None
"""Node metric data (API v1.10)"""
node_uuid: str = Field(alias="nodeUuid")
node_name: str = Field(alias="nodeName")
country_emoji: str = Field(alias="countryEmoji")
provider_name: str = Field(alias="providerName")
users_online: float = Field(alias="usersOnline")
inbounds_stats: List[TrafficStatDto] = Field(alias="inboundsStats")
outbounds_stats: List[TrafficStatDto] = Field(alias="outboundsStats")
@property
def uuid(self) -> str:
return self.node_uuid
@property
def name(self) -> str:
return self.node_name
@property
def connected_users(self) -> float:
return self.users_online
@property
def cpu_usage(self) -> None:
return None
@property
def memory_usage(self) -> None:
return None
@property
def network_upload(self) -> None:
return None
@property
def network_download(self) -> None:
return None
@property
def uptime(self) -> None:
return None
@property
def last_seen(self) -> None:
return None
class GetNodesMetricsResponseDto(BaseModel):
@ -143,7 +191,11 @@ class X25519KeyPair(BaseModel):
class GetX25519KeyPairResponseDto(BaseModel):
key_pairs: List[X25519KeyPair] = Field(alias="keyPairs")
key_pairs: List[X25519KeyPair] = Field(alias="keypairs")
# OpenAPI v1.10 schema name
GenerateX25519ResponseDto = GetX25519KeyPairResponseDto
class EncryptHappCryptoLinkRequestDto(BaseModel):
@ -173,6 +225,27 @@ class DebugSrrMatcherData(BaseModel):
class DebugSrrMatcherResponseDto(DebugSrrMatcherData):
pass
class RecapThisMonth(BaseModel):
users: float
traffic: str
class RecapTotal(BaseModel):
users: float
nodes: float
traffic: str
nodes_ram: str = Field(alias="nodesRam")
nodes_cpu_cores: float = Field(alias="nodesCpuCores")
distinct_countries: float = Field(alias="distinctCountries")
class GetRecapResponseDto(BaseModel):
this_month: RecapThisMonth = Field(alias="thisMonth")
total: RecapTotal
version: str
init_date: datetime.datetime = Field(alias="initDate")
class BuildInfo(BaseModel):
"""Build information"""
time: str

View file

@ -109,8 +109,8 @@ class UpdateUserRequestDto(BaseModel):
class UserTrafficDto(BaseModel):
"""User traffic information"""
used_traffic_bytes: int = Field(alias="usedTrafficBytes")
lifetime_used_traffic_bytes: int = Field(alias="lifetimeUsedTrafficBytes")
used_traffic_bytes: float = Field(alias="usedTrafficBytes")
lifetime_used_traffic_bytes: float = Field(alias="lifetimeUsedTrafficBytes")
online_at: Optional[datetime] = Field(None, alias="onlineAt")
first_connected_at: Optional[datetime] = Field(None, alias="firstConnectedAt")
last_connected_node_uuid: Optional[UUID] = Field(None, alias="lastConnectedNodeUuid")
@ -149,12 +149,12 @@ class UserResponseDto(BaseModel):
user_traffic: UserTrafficDto = Field(alias="userTraffic")
@property
def used_traffic_bytes(self) -> int:
def used_traffic_bytes(self) -> float:
"""Backward compatibility property"""
return self.user_traffic.used_traffic_bytes
@property
def lifetime_used_traffic_bytes(self) -> int:
def lifetime_used_traffic_bytes(self) -> float:
"""Backward compatibility property"""
return self.user_traffic.lifetime_used_traffic_bytes
@ -203,6 +203,22 @@ class RevokeUserRequestDto(BaseModel):
description="Optional. If true, only passwords will be revoked without changing the short UUID.",
)
class ResolveUserRequestBodyDto(BaseModel):
"""Request DTO for resolving a user by any identifier"""
uuid: Optional[UUID] = None
id: Optional[int] = None
short_uuid: Optional[str] = Field(None, serialization_alias="shortUuid")
username: Optional[str] = None
class ResolveUserResponseDto(BaseModel):
"""Response DTO for resolved user"""
uuid: UUID
username: str
id: int
short_uuid: str = Field(alias="shortUuid")
class SubscriptionRequestRecord(BaseModel):
"""Subscription request history record"""
id: int

View file

@ -139,7 +139,7 @@ class BulkAllExtendExpirationDateRequestDto(BaseModel):
# Base Response DTOs (без обертки response)
class BulkResponseData(BaseModel):
"""Common bulk response with affected rows"""
affected_rows: int = Field(alias="affectedRows")
affected_rows: float = Field(alias="affectedRows")
class BulkEventResponseData(BaseModel):

View file

@ -4,7 +4,7 @@ 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
TUsersStatus, TUserEvents, TUserHwidDevicesEvents, TServiceEvents, TNodeEvents, TErrorsEvents, TCRMEvents, TTorrentBlockerEvents, TResetPeriods
)
# ---------------- USER ---------------- #
@ -223,6 +223,55 @@ class WebhookNodeConfigProfileDto(BaseModel):
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemInfoDto(BaseModel):
arch: str
cpus: int
cpu_model: str
memory_total: float
hostname: str
platform: str
release: str
type: str
version: str
network_interfaces: List[str]
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemInterfaceDto(BaseModel):
interface: str
rx_bytes_per_sec: float
tx_bytes_per_sec: float
rx_total: float
tx_total: float
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemStatsDto(BaseModel):
memory_free: float
memory_used: float
uptime: float
load_avg: List[float]
interface: Optional[NodeSystemInterfaceDto] = None
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeSystemDto(BaseModel):
info: NodeSystemInfoDto
stats: NodeSystemStatsDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeVersionsDto(BaseModel):
xray: str
node: str
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class NodeDto(BaseModel):
uuid: UUID
name: str
@ -234,11 +283,8 @@ class NodeDto(BaseModel):
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
xray_uptime: float = 0
users_online: Optional[float] = None
is_traffic_tracking_active: bool
traffic_reset_day: Optional[int] = None
@ -252,10 +298,6 @@ class NodeDto(BaseModel):
tags: List[str] = Field(default_factory=list)
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: datetime
updated_at: datetime
@ -264,6 +306,10 @@ class NodeDto(BaseModel):
provider_uuid: Optional[UUID] = None
provider: Optional[InfraProviderDto] = None
active_plugin_uuid: Optional[UUID] = None
system: Optional[NodeSystemDto] = None
versions: Optional[NodeVersionsDto] = None
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# Backward-compat shims for code that used the flat fields directly
@ -318,6 +364,21 @@ class CrmEventDto(BaseModel):
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# ---------------- TORRENT BLOCKER EVENTS ---------------- #
class TorrentBlockerReportDto(BaseModel):
node: NodeDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
class TorrentBlockerEventDto(BaseModel):
event_name: TTorrentBlockerEvents
data: TorrentBlockerReportDto
model_config = {"alias_generator": to_camel, "populate_by_name": True}
# ---------------- WEBHOOK PAYLOAD ---------------- #
class WebhookPayloadDto(BaseModel):

View file

@ -1,20 +0,0 @@
#!/usr/bin/env python3
"""Quick test to verify all new imports work"""
try:
from remnawave.models import (
ReorderConfigProfilesRequestDto,
ReorderSubscriptionTemplatesRequestDto,
ReorderInternalSquadsRequestDto,
ReorderExternalSquadsRequestDto,
GetSubpageConfigByShortUuidResponseDto,
)
print("Все новые модели успешно импортируются!")
print(" - ReorderConfigProfilesRequestDto")
print(" - ReorderSubscriptionTemplatesRequestDto")
print(" - ReorderInternalSquadsRequestDto")
print(" - ReorderExternalSquadsRequestDto")
print(" - GetSubpageConfigByShortUuidResponseDto")
except ImportError as e:
print(f"❌ Ошибка импорта: {e}")
exit(1)

View file

@ -6,4 +6,5 @@ REMNAWAVE_INBOUND_UUID=
REMNAWAVE_USER_UUID=
REMNAWAVE_SHORT_UUID=
REMNAWAVE_CONFIG_PROFILE_UUID=
REMNAWAVE_USER_USERNAME=
REMNAWAVE_USER_USERNAME=
REMNAWAVE_NODE_UUID=

View file

@ -15,6 +15,7 @@ REMNAWAVE_CONFIG_PROFILE_UUID = os.getenv("REMNAWAVE_CONFIG_PROFILE_UUID")
REMNAWAVE_USER_UUID = os.getenv("REMNAWAVE_USER_UUID")
REMNAWAVE_SHORT_UUID = os.getenv("REMNAWAVE_SHORT_UUID")
REMNAWAVE_USER_USERNAME = os.getenv("REMNAWAVE_USER_USERNAME")
REMNAWAVE_NODE_UUID = os.getenv("REMNAWAVE_NODE_UUID")
@pytest.fixture
async def remnawave() -> RemnawaveSDK:
@ -44,4 +45,6 @@ async def remnawave() -> RemnawaveSDK:
assert sdk.subscription_page_config is not None
assert sdk.xray_config is not None
assert sdk.hwid is not None
assert sdk.node_plugins is not None
assert sdk.metadata is not None
return sdk

View file

@ -1,5 +1,4 @@
import pytest
from uuid import UUID
from remnawave.models import (
# Legacy models (deprecated)
@ -7,11 +6,10 @@ from remnawave.models import (
GetNodesRealtimeUsageResponseDto,
GetNodeUserUsageByRangeResponseDto,
GetUserUsageByRangeResponseDto,
# New stats models
GetLegacyStatsUserUsageResponseDto,
GetLegacyStatsNodesUsersUsageResponseDto,
GetStatsNodesRealtimeUsageResponseDto,
GetStatsNodesUsageResponseDto,
GetStatsNodeUsersUsageResponseDto,
GetStatsUserUsageResponseDto,
@ -69,24 +67,6 @@ async def test_legacy_node_user_usage(remnawave):
assert len(node_user_usage) >= 0
@pytest.mark.asyncio
async def test_stats_nodes_realtime_usage(remnawave):
"""Test new stats nodes realtime usage endpoint"""
realtime_usage = await remnawave.bandwidthstats.get_nodes_realtime_usage()
assert isinstance(realtime_usage, GetStatsNodesRealtimeUsageResponseDto)
assert hasattr(realtime_usage, 'response')
assert isinstance(realtime_usage.response, list)
# Check structure if data exists
if realtime_usage.response:
first_item = realtime_usage.response[0]
assert hasattr(first_item, 'node_uuid')
assert hasattr(first_item, 'node_name')
assert hasattr(first_item, 'download_bytes')
assert hasattr(first_item, 'upload_bytes')
assert hasattr(first_item, 'total_bytes')
@pytest.mark.asyncio
async def test_stats_nodes_usage(remnawave):
"""Test new stats nodes usage endpoint with charts"""
@ -232,20 +212,7 @@ async def test_legacy_stats_nodes_users_usage(remnawave):
async def test_bandwidth_data_structure(remnawave):
"""Test bandwidth stats data structure validity"""
start, end = generate_date_range()
# Get realtime data
realtime = await remnawave.bandwidthstats.get_nodes_realtime_usage()
if realtime.response:
# Verify each node has required fields
for node in realtime.response:
assert isinstance(node.node_uuid, UUID)
assert isinstance(node.node_name, str)
assert isinstance(node.download_bytes, (int, float))
assert isinstance(node.upload_bytes, (int, float))
assert isinstance(node.total_bytes, (int, float))
assert node.total_bytes >= 0
# Get stats data
stats = await remnawave.bandwidthstats.get_stats_nodes_usage(
start=start,

View file

@ -0,0 +1,119 @@
"""Tests that all required endpoints exist in controllers."""
import pytest
import inspect
from remnawave.controllers.users import UsersController
from remnawave.controllers.system import SystemController
from remnawave.controllers.ip_control import IpControlController
class TestUsersControllerEndpoints:
def test_has_resolve_user(self):
assert hasattr(UsersController, "resolve_user")
assert callable(getattr(UsersController, "resolve_user"))
def test_has_revoke_user_subscription(self):
assert hasattr(UsersController, "revoke_user_subscription")
def test_has_disable_user(self):
assert hasattr(UsersController, "disable_user")
def test_has_enable_user(self):
assert hasattr(UsersController, "enable_user")
def test_has_reset_user_traffic(self):
assert hasattr(UsersController, "reset_user_traffic")
def test_has_create_user(self):
assert hasattr(UsersController, "create_user")
def test_has_update_user(self):
assert hasattr(UsersController, "update_user")
def test_has_delete_user(self):
assert hasattr(UsersController, "delete_user")
def test_has_get_all_users(self):
assert hasattr(UsersController, "get_all_users")
def test_has_get_user_by_uuid(self):
assert hasattr(UsersController, "get_user_by_uuid")
def test_has_get_user_by_short_uuid(self):
assert hasattr(UsersController, "get_user_by_short_uuid")
def test_has_get_user_by_username(self):
assert hasattr(UsersController, "get_user_by_username")
def test_has_get_user_by_id(self):
assert hasattr(UsersController, "get_user_by_id")
def test_has_get_users_by_telegram_id(self):
assert hasattr(UsersController, "get_users_by_telegram_id")
def test_has_get_users_by_email(self):
assert hasattr(UsersController, "get_users_by_email")
def test_has_get_users_by_tag(self):
assert hasattr(UsersController, "get_users_by_tag")
def test_has_get_all_tags(self):
assert hasattr(UsersController, "get_all_tags")
def test_has_get_user_accessible_nodes(self):
assert hasattr(UsersController, "get_user_accessible_nodes")
def test_has_get_user_subscription_request_history(self):
assert hasattr(UsersController, "get_user_subscription_request_history")
class TestSystemControllerEndpoints:
def test_has_get_recap(self):
assert hasattr(SystemController, "get_recap")
assert callable(getattr(SystemController, "get_recap"))
def test_has_get_metadata(self):
assert hasattr(SystemController, "get_metadata")
def test_has_get_stats(self):
assert hasattr(SystemController, "get_stats")
def test_has_get_bandwidth_stats(self):
assert hasattr(SystemController, "get_bandwidth_stats")
def test_has_get_nodes_statistics(self):
assert hasattr(SystemController, "get_nodes_statistics")
def test_has_get_health(self):
assert hasattr(SystemController, "get_health")
def test_has_get_nodes_metrics(self):
assert hasattr(SystemController, "get_nodes_metrics")
def test_has_get_x25519_key_pair(self):
assert hasattr(SystemController, "get_x25519_key_pair")
def test_has_encrypt_happ_crypto_link(self):
assert hasattr(SystemController, "encrypt_happ_crypto_link")
def test_has_debug_srr_matcher(self):
assert hasattr(SystemController, "debug_srr_matcher")
class TestIpControlControllerEndpoints:
def test_has_fetch_user_ips(self):
assert hasattr(IpControlController, "fetch_user_ips")
def test_has_get_fetch_ips_result(self):
assert hasattr(IpControlController, "get_fetch_ips_result")
def test_has_fetch_users_ips(self):
assert hasattr(IpControlController, "fetch_users_ips")
assert callable(getattr(IpControlController, "fetch_users_ips"))
def test_has_get_fetch_users_ips_result(self):
assert hasattr(IpControlController, "get_fetch_users_ips_result")
assert callable(getattr(IpControlController, "get_fetch_users_ips_result"))
def test_has_drop_connections(self):
assert hasattr(IpControlController, "drop_connections")

131
tests/test_enums.py Normal file
View file

@ -0,0 +1,131 @@
"""Tests for enum completeness against the OpenAPI spec."""
import pytest
from remnawave.enums import (
ALPN,
ClientType,
Fingerprint,
OAuth2Provider,
SecurityLayer,
TemplateType,
TrafficLimitStrategy,
UserStatus,
ResponseRuleConditionOperator,
ResponseRuleOperator,
ResponseRuleVersion,
ResponseType,
SubscriptionType,
TTorrentBlockerEvents,
)
class TestOAuth2Provider:
def test_has_telegram(self):
assert OAuth2Provider.TELEGRAM == "telegram"
def test_has_generic(self):
assert OAuth2Provider.GENERIC == "generic"
def test_has_github(self):
assert OAuth2Provider.GITHUB == "github"
def test_has_pocketid(self):
assert OAuth2Provider.POCKETID == "pocketid"
def test_has_yandex(self):
assert OAuth2Provider.YANDEX == "yandex"
def test_has_keycloak(self):
assert OAuth2Provider.KEYCLOAK == "keycloak"
def test_all_values(self):
expected = {"telegram", "github", "pocketid", "yandex", "keycloak", "generic"}
actual = {v.value for v in OAuth2Provider}
assert actual == expected
class TestTemplateType:
def test_has_xray_base64(self):
assert TemplateType.XRAY_BASE64 == "XRAY_BASE64"
def test_has_xray_json(self):
assert TemplateType.XRAY_JSON == "XRAY_JSON"
def test_all_api_values(self):
api_values = {"XRAY_JSON", "XRAY_BASE64", "MIHOMO", "STASH", "CLASH", "SINGBOX"}
actual = {v.value for v in TemplateType}
assert api_values.issubset(actual)
class TestTrafficLimitStrategy:
def test_has_month_rolling(self):
assert TrafficLimitStrategy.MONTH_ROLLING == "MONTH_ROLLING"
def test_all_api_values(self):
api_values = {"NO_RESET", "DAY", "WEEK", "MONTH", "MONTH_ROLLING"}
actual = {v.value for v in TrafficLimitStrategy}
assert api_values == actual
class TestUserStatus:
def test_all_values(self):
expected = {"ACTIVE", "DISABLED", "LIMITED", "EXPIRED"}
actual = {v.value for v in UserStatus}
assert actual == expected
class TestClientType:
def test_api_values_present(self):
api_values = {"stash", "singbox", "mihomo", "json", "v2ray-json", "clash"}
actual = {v.value for v in ClientType}
assert api_values.issubset(actual)
class TestSecurityLayer:
def test_all_values(self):
expected = {"DEFAULT", "TLS", "NONE"}
actual = {v.value for v in SecurityLayer}
assert actual == expected
class TestFingerprint:
def test_all_api_values(self):
api_values = {"chrome", "firefox", "safari", "ios", "android", "edge", "qq", "random", "randomized"}
actual = {v.value for v in Fingerprint}
assert api_values == actual
class TestALPN:
def test_has_h3(self):
assert "h3" in {v.value for v in ALPN}
def test_has_h2(self):
assert "h2" in {v.value for v in ALPN}
class TestResponseRuleOperator:
def test_all_values(self):
expected = {"AND", "OR"}
actual = {v.value for v in ResponseRuleOperator}
assert actual == expected
class TestResponseRuleConditionOperator:
def test_all_values(self):
expected = {
"EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_CONTAINS",
"STARTS_WITH", "NOT_STARTS_WITH", "ENDS_WITH", "NOT_ENDS_WITH",
"REGEX", "NOT_REGEX",
}
actual = {v.value for v in ResponseRuleConditionOperator}
assert actual == expected
class TestResponseType:
def test_all_values(self):
expected = {
"XRAY_JSON", "XRAY_BASE64", "MIHOMO", "STASH", "CLASH", "SINGBOX",
"BROWSER", "BLOCK", "STATUS_CODE_404", "STATUS_CODE_451", "SOCKET_DROP",
}
actual = {v.value for v in ResponseType}
assert actual == expected

218
tests/test_metadata.py Normal file
View file

@ -0,0 +1,218 @@
import os
from datetime import datetime, timedelta, timezone
import pytest
from remnawave.exceptions import NotFoundError
from remnawave.models import (
CreateUserRequestDto,
GetNodeMetadataResponseDto,
GetUserMetadataResponseDto,
UpsertNodeMetadataRequestBodyDto,
UpsertNodeMetadataResponseDto,
UpsertUserMetadataRequestBodyDto,
UpsertUserMetadataResponseDto,
)
from tests.utils import generate_random_string
REMNAWAVE_NODE_UUID = os.getenv("REMNAWAVE_NODE_UUID")
class TestUserMetadata:
"""Тесты для User Metadata контроллера"""
@pytest.mark.asyncio
async def test_upsert_and_get_user_metadata(self, remnawave):
"""Тест создания/обновления и получения метаданных пользователя"""
# Create test user first
username = generate_random_string(length=8)
expire_at = datetime.now(timezone.utc) + timedelta(days=7)
create_user = await remnawave.users.create_user(
CreateUserRequestDto(username=username, expire_at=expire_at)
)
user_uuid = str(create_user.uuid)
try:
# Upsert metadata
test_metadata = {
"custom_field_1": "test_value_1",
"custom_field_2": 123,
"nested": {
"field": "value"
}
}
upsert_response = await remnawave.metadata.upsert_user_metadata(
uuid=user_uuid,
body=UpsertUserMetadataRequestBodyDto(metadata=test_metadata)
)
assert isinstance(upsert_response, UpsertUserMetadataResponseDto)
# Get metadata
get_response = await remnawave.metadata.get_user_metadata(uuid=user_uuid)
assert isinstance(get_response, GetUserMetadataResponseDto)
assert hasattr(get_response, "metadata")
assert get_response.metadata is not None
assert get_response.metadata.get("custom_field_1") == "test_value_1"
assert get_response.metadata.get("custom_field_2") == 123
assert get_response.metadata.get("nested") == {"field": "value"}
finally:
# Cleanup
await remnawave.users.delete_user(uuid=user_uuid)
@pytest.mark.asyncio
async def test_update_existing_user_metadata(self, remnawave):
"""Тест обновления существующих метаданных пользователя"""
# Create test user
username = generate_random_string(length=8)
expire_at = datetime.now(timezone.utc) + timedelta(days=7)
create_user = await remnawave.users.create_user(
CreateUserRequestDto(username=username, expire_at=expire_at)
)
user_uuid = str(create_user.uuid)
try:
# Initial metadata
initial_metadata = {"field1": "value1"}
await remnawave.metadata.upsert_user_metadata(
uuid=user_uuid,
body=UpsertUserMetadataRequestBodyDto(metadata=initial_metadata)
)
# Update metadata
updated_metadata = {
"field1": "updated_value1",
"field2": "value2"
}
upsert_response = await remnawave.metadata.upsert_user_metadata(
uuid=user_uuid,
body=UpsertUserMetadataRequestBodyDto(metadata=updated_metadata)
)
assert isinstance(upsert_response, UpsertUserMetadataResponseDto)
# Verify update
get_response = await remnawave.metadata.get_user_metadata(uuid=user_uuid)
assert get_response.metadata.get("field1") == "updated_value1"
assert get_response.metadata.get("field2") == "value2"
finally:
# Cleanup
await remnawave.users.delete_user(uuid=user_uuid)
@pytest.mark.asyncio
async def test_get_user_metadata_empty(self, remnawave):
"""Тест получения пустых метаданных пользователя"""
# Create test user without metadata
username = generate_random_string(length=8)
expire_at = datetime.now(timezone.utc) + timedelta(days=7)
create_user = await remnawave.users.create_user(
CreateUserRequestDto(username=username, expire_at=expire_at)
)
user_uuid = str(create_user.uuid)
try:
# Get metadata (API может вернуть пустой объект или 404, если метаданные не созданы)
try:
get_response = await remnawave.metadata.get_user_metadata(uuid=user_uuid)
assert isinstance(get_response, GetUserMetadataResponseDto)
assert get_response.metadata is None or get_response.metadata == {}
except NotFoundError:
# Ожидаемое поведение для пользователя без метаданных
assert True
finally:
# Cleanup
await remnawave.users.delete_user(uuid=user_uuid)
class TestNodeMetadata:
"""Тесты для Node Metadata контроллера"""
@pytest.mark.asyncio
async def test_upsert_and_get_node_metadata(self, remnawave):
"""Тест создания/обновления и получения метаданных ноды"""
# Skip if no node UUID configured
if not REMNAWAVE_NODE_UUID:
pytest.skip("REMNAWAVE_NODE_UUID not set in environment")
node_uuid = REMNAWAVE_NODE_UUID
# Upsert metadata
test_metadata = {
"location": "datacenter-1",
"region": "us-east",
"capacity": 1000,
"config": {
"max_users": 100
}
}
upsert_response = await remnawave.metadata.upsert_node_metadata(
uuid=node_uuid,
body=UpsertNodeMetadataRequestBodyDto(metadata=test_metadata)
)
assert isinstance(upsert_response, UpsertNodeMetadataResponseDto)
# Get metadata
get_response = await remnawave.metadata.get_node_metadata(uuid=node_uuid)
assert isinstance(get_response, GetNodeMetadataResponseDto)
assert hasattr(get_response, "metadata")
assert get_response.metadata is not None
assert get_response.metadata.get("location") == "datacenter-1"
assert get_response.metadata.get("region") == "us-east"
assert get_response.metadata.get("capacity") == 1000
assert get_response.metadata.get("config") == {"max_users": 100}
@pytest.mark.asyncio
async def test_update_existing_node_metadata(self, remnawave):
"""Тест обновления существующих метаданных ноды"""
if not REMNAWAVE_NODE_UUID:
pytest.skip("REMNAWAVE_NODE_UUID not set in environment")
node_uuid = REMNAWAVE_NODE_UUID
# Initial metadata
initial_metadata = {"status": "active"}
await remnawave.metadata.upsert_node_metadata(
uuid=node_uuid,
body=UpsertNodeMetadataRequestBodyDto(metadata=initial_metadata)
)
# Update metadata
updated_metadata = {
"status": "maintenance",
"last_check": "2026-03-09"
}
upsert_response = await remnawave.metadata.upsert_node_metadata(
uuid=node_uuid,
body=UpsertNodeMetadataRequestBodyDto(metadata=updated_metadata)
)
assert isinstance(upsert_response, UpsertNodeMetadataResponseDto)
# Verify update
get_response = await remnawave.metadata.get_node_metadata(uuid=node_uuid)
assert get_response.metadata.get("status") == "maintenance"
assert get_response.metadata.get("last_check") == "2026-03-09"
@pytest.mark.asyncio
async def test_get_node_metadata(self, remnawave):
"""Тест получения метаданных ноды"""
if not REMNAWAVE_NODE_UUID:
pytest.skip("REMNAWAVE_NODE_UUID not set in environment")
node_uuid = REMNAWAVE_NODE_UUID
# Get metadata
get_response = await remnawave.metadata.get_node_metadata(uuid=node_uuid)
assert isinstance(get_response, GetNodeMetadataResponseDto)
assert hasattr(get_response, "metadata")
# Metadata can be empty dict or None or contain data depending on previous tests

View file

@ -0,0 +1,277 @@
"""Tests for model field validation and serialization."""
import pytest
from datetime import datetime, timezone
from uuid import uuid4
from remnawave.models import (
# Users
ResolveUserRequestBodyDto,
ResolveUserResponseDto,
RevokeUserRequestDto,
# System
GetRecapResponseDto,
RecapThisMonth,
RecapTotal,
# IP Control
FetchUsersIpsResponseDto,
FetchUsersIpsResultResponseDto,
FetchUsersIpsUserIp,
FetchUsersIpsUser,
FetchUsersIpsResult,
DropConnectionsRequestDto,
DropByUserUuids,
DropByIpAddresses,
TargetAllNodes,
TargetSpecificNodes,
# Infra Billing
CreateInfraBillingHistoryRecordRequestDto,
CreateInfraBillingNodeRequestDto,
# Subscription Settings
ResponseRules,
ResponseRulesSettings,
# Webhook
NodeSystemDto,
NodeSystemInfoDto,
NodeSystemStatsDto,
NodeVersionsDto,
)
from remnawave.enums import ResponseRuleVersion
class TestResolveUserRequestBodyDto:
def test_create_with_uuid(self):
uid = uuid4()
dto = ResolveUserRequestBodyDto(uuid=uid)
assert dto.uuid == uid
assert dto.id is None
assert dto.username is None
def test_create_with_username(self):
dto = ResolveUserRequestBodyDto(username="testuser")
assert dto.username == "testuser"
assert dto.uuid is None
def test_create_with_short_uuid(self):
dto = ResolveUserRequestBodyDto(short_uuid="abc123")
assert dto.short_uuid == "abc123"
def test_serialization_alias(self):
dto = ResolveUserRequestBodyDto(short_uuid="abc123")
data = dto.model_dump(by_alias=True)
assert "shortUuid" in data
def test_create_with_id(self):
dto = ResolveUserRequestBodyDto(id=42)
assert dto.id == 42
class TestResolveUserResponseDto:
def test_from_api_response(self):
uid = uuid4()
dto = ResolveUserResponseDto(
uuid=uid,
username="testuser",
id=1,
shortUuid="abc123",
)
assert dto.uuid == uid
assert dto.username == "testuser"
assert dto.id == 1
assert dto.short_uuid == "abc123"
class TestGetRecapResponseDto:
def test_from_api_response(self):
dto = GetRecapResponseDto(
thisMonth={"users": 10, "traffic": "1.5 GB"},
total={
"users": 100,
"nodes": 5,
"traffic": "500 GB",
"nodesRam": "32 GB",
"nodesCpuCores": 16,
"distinctCountries": 3,
},
version="1.11.0",
initDate="2025-01-01T00:00:00Z",
)
assert dto.this_month.users == 10
assert dto.this_month.traffic == "1.5 GB"
assert dto.total.nodes == 5
assert dto.total.nodes_ram == "32 GB"
assert dto.total.nodes_cpu_cores == 16
assert dto.total.distinct_countries == 3
assert dto.version == "1.11.0"
assert isinstance(dto.init_date, datetime)
class TestFetchUsersIpsModels:
def test_response_dto(self):
dto = FetchUsersIpsResponseDto(jobId="job-123")
assert dto.job_id == "job-123"
def test_result_not_completed(self):
dto = FetchUsersIpsResultResponseDto(
isCompleted=False,
isFailed=False,
result=None,
)
assert dto.is_completed is False
assert dto.is_failed is False
assert dto.result is None
def test_result_completed(self):
uid = uuid4()
dto = FetchUsersIpsResultResponseDto(
isCompleted=True,
isFailed=False,
result={
"success": True,
"nodeUuid": str(uid),
"users": [
{
"userId": "user-1",
"ips": [
{"ip": "1.2.3.4", "lastSeen": "2025-01-01T00:00:00Z"},
],
}
],
},
)
assert dto.is_completed is True
assert dto.result.success is True
assert dto.result.node_uuid == uid
assert len(dto.result.users) == 1
assert dto.result.users[0].user_id == "user-1"
assert dto.result.users[0].ips[0].ip == "1.2.3.4"
class TestCreateInfraBillingHistoryRecordRequestDto:
def test_fields_match_spec(self):
uid = uuid4()
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingHistoryRecordRequestDto(
provider_uuid=uid,
amount=29.99,
billed_at=now,
)
assert dto.provider_uuid == uid
assert dto.amount == 29.99
assert dto.billed_at == now
def test_serialization(self):
uid = uuid4()
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingHistoryRecordRequestDto(
provider_uuid=uid,
amount=10.0,
billed_at=now,
)
data = dto.model_dump(by_alias=True)
assert "providerUuid" in data
assert "billedAt" in data
assert "amount" in data
def test_no_old_fields(self):
"""Ensure removed fields don't exist."""
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "node_uuid")
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "payment_date")
assert not hasattr(CreateInfraBillingHistoryRecordRequestDto, "description")
class TestCreateInfraBillingNodeRequestDto:
def test_next_billing_at_optional(self):
dto = CreateInfraBillingNodeRequestDto(
node_uuid=uuid4(),
provider_uuid=uuid4(),
)
assert dto.next_billing_at is None
def test_next_billing_at_provided(self):
now = datetime.now(tz=timezone.utc)
dto = CreateInfraBillingNodeRequestDto(
node_uuid=uuid4(),
provider_uuid=uuid4(),
next_billing_at=now,
)
assert dto.next_billing_at == now
class TestResponseRulesSettings:
def test_settings_field_exists(self):
rules = ResponseRules(
version=ResponseRuleVersion.V1,
rules=[],
settings=ResponseRulesSettings(
disable_subscription_access_by_path=True,
),
)
assert rules.settings is not None
assert rules.settings.disable_subscription_access_by_path is True
def test_settings_optional(self):
rules = ResponseRules(
version=ResponseRuleVersion.V1,
rules=[],
)
assert rules.settings is None
def test_settings_deserialization(self):
rules = ResponseRules.model_validate({
"version": "1",
"rules": [],
"settings": {"disableSubscriptionAccessByPath": False},
})
assert rules.settings.disable_subscription_access_by_path is False
class TestWebhookNodeDto:
def test_system_field(self):
system = NodeSystemDto.model_validate({
"info": {
"arch": "x64",
"cpus": 4,
"cpuModel": "Intel Core i7",
"memoryTotal": 16384,
"hostname": "node-1",
"platform": "linux",
"release": "5.15.0",
"type": "Linux",
"version": "#1 SMP",
"networkInterfaces": ["eth0", "lo"],
},
"stats": {
"memoryFree": 8192,
"memoryUsed": 8192,
"uptime": 3600,
"loadAvg": [0.5, 0.3, 0.1],
"interface": None,
},
})
assert system.info.arch == "x64"
assert system.info.cpus == 4
assert system.info.cpu_model == "Intel Core i7"
assert system.stats.memory_free == 8192
assert system.stats.uptime == 3600
def test_versions_field(self):
versions = NodeVersionsDto.model_validate({
"xray": "1.8.6",
"node": "0.5.0",
})
assert versions.xray == "1.8.6"
assert versions.node == "0.5.0"
def test_node_dto_has_new_fields(self):
from remnawave.models.webhook import NodeDto
fields = NodeDto.model_fields
assert "active_plugin_uuid" in fields
assert "system" in fields
assert "versions" in fields
def test_node_dto_xray_uptime_is_float(self):
from remnawave.models.webhook import NodeDto
field = NodeDto.model_fields["xray_uptime"]
assert field.annotation == float or field.annotation is float

257
tests/test_node_plugins.py Normal file
View file

@ -0,0 +1,257 @@
import pytest
from remnawave.exceptions import NotFoundError
from remnawave.models import (
CloneNodePluginRequestDto,
CloneNodePluginResponseDto,
CreateNodePluginRequestDto,
CreateNodePluginResponseDto,
DeleteNodePluginResponseDto,
GetNodePluginResponseDto,
GetNodePluginsResponseDto,
GetTorrentBlockerReportsResponseDto,
GetTorrentBlockerReportsStatsResponseDto,
PluginExecutorRequestDto,
PluginExecutorResponseDto,
ReorderNodePluginsRequestDto,
ReorderNodePluginsResponseDto,
TruncateTorrentBlockerReportsResponseDto,
UpdateNodePluginRequestDto,
UpdateNodePluginResponseDto,
BlockIpsCommandDto,
BlockIpItemDto,
ReorderNodePluginItem,
TargetAllNodesDto,
)
from tests.utils import generate_random_string
class TestNodePlugins:
"""Тесты для Node Plugins контроллера"""
@pytest.mark.asyncio
async def test_get_all_node_plugins(self, remnawave):
"""Тест получения списка всех Node Plugins"""
response = await remnawave.node_plugins.get_all_node_plugins()
assert isinstance(response, GetNodePluginsResponseDto)
assert hasattr(response, "node_plugins")
assert isinstance(response.node_plugins, list)
@pytest.mark.asyncio
async def test_create_and_delete_node_plugin(self, remnawave):
"""Тест создания и удаления Node Plugin"""
plugin_name = f"test_plugin_{generate_random_string(length=8)}"
# Create plugin
create_response = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin_name
)
)
assert isinstance(create_response, CreateNodePluginResponseDto)
assert create_response.uuid is not None
plugin_uuid = str(create_response.uuid)
# Get plugin by UUID to verify creation
get_response = await remnawave.node_plugins.get_node_plugin_by_uuid(uuid=plugin_uuid)
assert isinstance(get_response, GetNodePluginResponseDto)
assert get_response.name == plugin_name
# Delete plugin
delete_response = await remnawave.node_plugins.delete_node_plugin(uuid=plugin_uuid)
assert isinstance(delete_response, DeleteNodePluginResponseDto)
@pytest.mark.asyncio
async def test_update_node_plugin(self, remnawave):
"""Тест обновления Node Plugin"""
plugin_name = f"test_plugin_{generate_random_string(length=8)}"
# Create plugin first
create_response = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin_name
)
)
plugin_uuid = str(create_response.uuid)
try:
# Update plugin
updated_name = f"updated_{plugin_name}"
update_response = await remnawave.node_plugins.update_node_plugin(
UpdateNodePluginRequestDto(
uuid=plugin_uuid,
name=updated_name,
plugin_config={"enabled": False}
)
)
assert isinstance(update_response, UpdateNodePluginResponseDto)
# Verify update
get_response = await remnawave.node_plugins.get_node_plugin_by_uuid(uuid=plugin_uuid)
assert get_response.name == updated_name
finally:
# Cleanup
await remnawave.node_plugins.delete_node_plugin(uuid=plugin_uuid)
@pytest.mark.asyncio
async def test_reorder_node_plugins(self, remnawave):
"""Тест изменения порядка Node Plugins"""
# Create two plugins
plugin1_name = f"test_plugin_1_{generate_random_string(length=6)}"
plugin2_name = f"test_plugin_2_{generate_random_string(length=6)}"
create1 = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin1_name
)
)
uuid1 = str(create1.uuid)
create2 = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin2_name
)
)
uuid2 = str(create2.uuid)
try:
# Reorder plugins
reorder_response = await remnawave.node_plugins.reorder_node_plugins(
ReorderNodePluginsRequestDto(
items=[
ReorderNodePluginItem(view_position=0, uuid=uuid2),
ReorderNodePluginItem(view_position=1, uuid=uuid1),
]
)
)
assert isinstance(reorder_response, ReorderNodePluginsResponseDto)
finally:
# Cleanup
await remnawave.node_plugins.delete_node_plugin(uuid=uuid1)
await remnawave.node_plugins.delete_node_plugin(uuid=uuid2)
@pytest.mark.asyncio
async def test_clone_node_plugin(self, remnawave):
"""Тест клонирования Node Plugin"""
plugin_name = f"test_plugin_{generate_random_string(length=8)}"
# Create plugin
create_response = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin_name
)
)
original_uuid = str(create_response.uuid)
try:
# Clone plugin
clone_response = await remnawave.node_plugins.clone_node_plugin(
CloneNodePluginRequestDto(
clone_from_uuid=original_uuid,
)
)
assert isinstance(clone_response, CloneNodePluginResponseDto)
cloned_uuid = str(clone_response.uuid)
# Verify clone
get_cloned = await remnawave.node_plugins.get_node_plugin_by_uuid(uuid=cloned_uuid)
assert get_cloned.uuid == clone_response.uuid
# Cleanup cloned plugin
await remnawave.node_plugins.delete_node_plugin(uuid=cloned_uuid)
finally:
# Cleanup original plugin
await remnawave.node_plugins.delete_node_plugin(uuid=original_uuid)
@pytest.mark.asyncio
async def test_plugin_executor(self, remnawave):
"""Тест выполнения команды на плагинах"""
# This test assumes there's at least one node plugin configured
# Create a test plugin first
plugin_name = f"test_plugin_{generate_random_string(length=8)}"
create_response = await remnawave.node_plugins.create_node_plugin(
CreateNodePluginRequestDto(
name=plugin_name
)
)
plugin_uuid = str(create_response.uuid)
try:
# Execute command
try:
executor_response = await remnawave.node_plugins.plugin_executor(
PluginExecutorRequestDto(
command=BlockIpsCommandDto(
command="blockIps",
ips=[
BlockIpItemDto(ip="192.168.1.1", timeout=60),
BlockIpItemDto(ip="10.0.0.1", timeout=60),
],
),
target_nodes=TargetAllNodesDto(target="allNodes"),
)
)
assert isinstance(executor_response, PluginExecutorResponseDto)
except NotFoundError:
# В тестовых окружениях без подключенных нод API может вернуть 404
pytest.skip("Node plugins executor is unavailable in this environment (no connected nodes)")
finally:
# Cleanup
await remnawave.node_plugins.delete_node_plugin(uuid=plugin_uuid)
class TestTorrentBlocker:
"""Тесты для Torrent Blocker функциональности"""
@pytest.mark.asyncio
async def test_get_torrent_blocker_reports(self, remnawave):
"""Тест получения отчетов Torrent Blocker"""
response = await remnawave.node_plugins.get_torrent_blocker_reports(
size=10,
start=0
)
assert isinstance(response, GetTorrentBlockerReportsResponseDto)
assert hasattr(response, "records")
assert isinstance(response.records, list)
assert hasattr(response, "total")
@pytest.mark.asyncio
async def test_get_torrent_blocker_reports_without_pagination(self, remnawave):
"""Тест получения отчетов Torrent Blocker без пагинации"""
response = await remnawave.node_plugins.get_torrent_blocker_reports()
assert isinstance(response, GetTorrentBlockerReportsResponseDto)
assert hasattr(response, "records")
assert isinstance(response.records, list)
@pytest.mark.asyncio
async def test_get_torrent_blocker_stats(self, remnawave):
"""Тест получения статистики Torrent Blocker"""
response = await remnawave.node_plugins.get_torrent_blocker_reports_stats()
assert isinstance(response, GetTorrentBlockerReportsStatsResponseDto)
assert hasattr(response, "stats")
@pytest.mark.asyncio
async def test_truncate_torrent_blocker_reports(self, remnawave):
"""Тест очистки отчетов Torrent Blocker"""
# This is a destructive operation, so be careful
# Only run in test environment
response = await remnawave.node_plugins.truncate_torrent_blocker_reports()
assert isinstance(response, TruncateTorrentBlockerReportsResponseDto)
# Verify truncation by checking reports are empty
reports = await remnawave.node_plugins.get_torrent_blocker_reports()
assert len(reports.records) == 0

View file

@ -50,11 +50,16 @@ class TestSubscriptionContent:
@pytest.mark.asyncio
async def test_get_subscription_with_type(self, remnawave):
"""Тест получения подписки с типом"""
subscription_with_type = await remnawave.subscription.get_subscription_with_type(
short_uuid=REMNAWAVE_SHORT_UUID
)
assert isinstance(subscription_with_type, str)
assert len(subscription_with_type) > 0
try:
subscription_with_type = await remnawave.subscription.get_subscription_with_type(
short_uuid=REMNAWAVE_SHORT_UUID
)
assert isinstance(subscription_with_type, str)
assert len(subscription_with_type) > 0
except ApiError as e:
if e.error.code == "HTTP_404":
pytest.skip("Outline subscription endpoint is unavailable in this environment")
raise
class TestSubscriptionsManagement: