Python-3xui 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_3xui/__init__.py +5 -0
- python_3xui/api.py +492 -0
- python_3xui/base_model.py +93 -0
- python_3xui/endpoints.py +361 -0
- python_3xui/models.py +277 -0
- python_3xui/util.py +265 -0
- python_3xui-0.0.1.dist-info/METADATA +38 -0
- python_3xui-0.0.1.dist-info/RECORD +10 -0
- python_3xui-0.0.1.dist-info/WHEEL +4 -0
- python_3xui-0.0.1.dist-info/licenses/LICENSE +201 -0
python_3xui/endpoints.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, UTC
|
|
3
|
+
from typing import Generic, Literal, List, Dict
|
|
4
|
+
|
|
5
|
+
from httpx import Response
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
from pydantic.main import ModelT
|
|
8
|
+
|
|
9
|
+
from . import models
|
|
10
|
+
from .api import XUIClient
|
|
11
|
+
from .models import Inbound, SingleInboundClient
|
|
12
|
+
from .util import JsonType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseEndpoint(Generic[ModelT]):
|
|
16
|
+
"""Base class for API endpoint handlers.
|
|
17
|
+
|
|
18
|
+
Provides common functionality for making API requests to the 3X-UI panel.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
_url: The base URL path for this endpoint group.
|
|
22
|
+
client: Reference to the XUIClient instance.
|
|
23
|
+
"""
|
|
24
|
+
_url: str
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: "XUIClient") -> None:
|
|
27
|
+
self.client = client
|
|
28
|
+
|
|
29
|
+
async def _simple_get(self, caller_endpoint: str) -> JsonType:
|
|
30
|
+
"""Perform a simple GET request and return the response object.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
caller_endpoint: The endpoint path to request. If it doesn't start
|
|
34
|
+
with the base URL, the base URL will be prepended.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The 'obj' field from the JSON response.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
RuntimeError: If the response status code is not 200.
|
|
41
|
+
"""
|
|
42
|
+
endpoint_url: str = caller_endpoint
|
|
43
|
+
if self._url not in caller_endpoint:
|
|
44
|
+
endpoint_url = f"{self._url}{caller_endpoint}"
|
|
45
|
+
resp = await self.client.safe_get(endpoint_url)
|
|
46
|
+
if resp.status_code == 200:
|
|
47
|
+
resp_json = resp.json()
|
|
48
|
+
return resp_json["obj"]
|
|
49
|
+
else:
|
|
50
|
+
raise RuntimeError(f"Error: wrong status code {resp.status_code}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Server(BaseEndpoint):
|
|
54
|
+
"""Handler for server-related API endpoints.
|
|
55
|
+
|
|
56
|
+
Provides methods for generating cryptographic keys and UUIDs.
|
|
57
|
+
|
|
58
|
+
Endpoints:
|
|
59
|
+
- /panel/api/server/getNewUUID
|
|
60
|
+
- /panel/api/server/getNewX25519Cert
|
|
61
|
+
- /panel/api/server/getNewmldsa65
|
|
62
|
+
- /panel/api/server/getNewmlkem768x
|
|
63
|
+
"""
|
|
64
|
+
_url = "panel/api/server"
|
|
65
|
+
|
|
66
|
+
async def new_uuid(self) -> str:
|
|
67
|
+
"""Generate a new UUID from the server.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A new UUID string.
|
|
71
|
+
"""
|
|
72
|
+
endpoint = "/getNewUUID"
|
|
73
|
+
resp_json = await self._simple_get(endpoint)
|
|
74
|
+
return resp_json["uuid"]
|
|
75
|
+
|
|
76
|
+
async def new_x25519(self) -> dict[Literal["privateKey", "publicKey"], str]:
|
|
77
|
+
"""Generate a new X25519 key pair.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A dictionary containing 'privateKey' and 'publicKey' strings.
|
|
81
|
+
"""
|
|
82
|
+
endpoint = "/getNewX25519Cert"
|
|
83
|
+
resp_json = await self._simple_get(endpoint)
|
|
84
|
+
return resp_json
|
|
85
|
+
|
|
86
|
+
async def new_mldsa65(self) -> dict[Literal["verify", "seed"], str]:
|
|
87
|
+
"""Generate a new ML-DSA-65 post-quantum key pair.
|
|
88
|
+
|
|
89
|
+
ML-DSA-65 is a post-quantum signature algorithm.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A dictionary containing 'verify' (public key) and 'seed' values.
|
|
93
|
+
"""
|
|
94
|
+
endpoint = "/getNewmldsa65"
|
|
95
|
+
resp_json = await self._simple_get(endpoint)
|
|
96
|
+
return resp_json
|
|
97
|
+
|
|
98
|
+
async def new_mlkem768(self) -> dict[Literal["client", "seed"], str]:
|
|
99
|
+
"""Generate a new ML-KEM-768 post-quantum key pair.
|
|
100
|
+
|
|
101
|
+
ML-KEM-768 is a post-quantum key encapsulation mechanism.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A dictionary containing 'client' and 'seed' values.
|
|
105
|
+
"""
|
|
106
|
+
endpoint = "/getNewmlkem768x"
|
|
107
|
+
resp_json = await self._simple_get(endpoint)
|
|
108
|
+
return resp_json
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Inbounds(BaseEndpoint):
|
|
112
|
+
"""Handler for inbound-related API endpoints.
|
|
113
|
+
|
|
114
|
+
Provides methods for retrieving inbound configurations.
|
|
115
|
+
|
|
116
|
+
Endpoints:
|
|
117
|
+
- /panel/api/inbounds/list
|
|
118
|
+
- /panel/api/inbounds/get/{id}
|
|
119
|
+
"""
|
|
120
|
+
_url = "panel/api/inbounds"
|
|
121
|
+
|
|
122
|
+
async def get_all(self) -> List[Inbound]:
|
|
123
|
+
"""Retrieve all inbounds from the server.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A list of Inbound model instances.
|
|
127
|
+
"""
|
|
128
|
+
endpoint = "/list"
|
|
129
|
+
json = await self._simple_get(f"{endpoint}")
|
|
130
|
+
inbounds = Inbound.from_list(json, client=self.client)
|
|
131
|
+
return inbounds
|
|
132
|
+
|
|
133
|
+
async def get_specific_inbound(self, id) -> Inbound:
|
|
134
|
+
"""Retrieve a specific inbound by ID.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
id: The ID of the inbound to retrieve.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
An Inbound model instance for the specified ID.
|
|
141
|
+
"""
|
|
142
|
+
endpoint = f"/get/{id}"
|
|
143
|
+
json = await self._simple_get(f"{endpoint}")
|
|
144
|
+
inbound = Inbound(client=self.client, **json)
|
|
145
|
+
return inbound
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Clients(BaseEndpoint):
|
|
149
|
+
"""Handler for client-related API endpoints.
|
|
150
|
+
|
|
151
|
+
Provides methods for retrieving, adding, updating, and deleting clients.
|
|
152
|
+
|
|
153
|
+
Endpoints:
|
|
154
|
+
- /panel/api/inbounds/getClientTraffics/{email}
|
|
155
|
+
- /panel/api/inbounds/getClientTrafficsById/{uuid}
|
|
156
|
+
- /panel/api/inbounds/addClient
|
|
157
|
+
- /panel/api/inbounds/updateClient/{uuid}
|
|
158
|
+
- /panel/api/inbounds/delDepletedClients/{inbound_id}
|
|
159
|
+
- /panel/api/inbounds/{inbound_id}/delClient/{email|uuid}
|
|
160
|
+
"""
|
|
161
|
+
_url = "panel/api/inbounds/"
|
|
162
|
+
|
|
163
|
+
#although it's the same url, they should be differentiated
|
|
164
|
+
|
|
165
|
+
async def get_client_with_email(self, email: str) -> models.ClientStats:
|
|
166
|
+
"""Retrieve client statistics by email.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
email: The client's email identifier.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A ClientStats model instance with the client's statistics.
|
|
173
|
+
"""
|
|
174
|
+
endpoint = f"getClientTraffics/{email}"
|
|
175
|
+
resp = await self._simple_get(endpoint)
|
|
176
|
+
return models.ClientStats.model_validate(resp)
|
|
177
|
+
|
|
178
|
+
async def get_client_with_uuid(self, uuid: str) -> List[models.ClientStats]:
|
|
179
|
+
"""Retrieve client statistics by UUID.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
uuid: The client's unique identifier.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A list of ClientStats model instances matching the UUID.
|
|
186
|
+
"""
|
|
187
|
+
endpoint = f"getClientTrafficsById/{uuid}"
|
|
188
|
+
resp = await self._simple_get(endpoint)
|
|
189
|
+
client_stats = models.ClientStats.from_list(resp, client=self.client)
|
|
190
|
+
return client_stats
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def add_client(self, client: models.InboundClients | models.SingleInboundClient | Dict,
|
|
194
|
+
inbound_id: int | None = None) -> Response:
|
|
195
|
+
"""Add a new client to an inbound.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
client: The client to add. Can be:
|
|
199
|
+
- A dict (will be parsed as JSON)
|
|
200
|
+
- A SingleInboundClient (requires inbound_id)
|
|
201
|
+
- An InboundClients object
|
|
202
|
+
inbound_id: The ID of the inbound to add the client to.
|
|
203
|
+
Required if client is a SingleInboundClient.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The HTTP response from the API.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ValueError: If a single client is provided without an inbound_id.
|
|
210
|
+
TypeError: If the client type is not supported.
|
|
211
|
+
"""
|
|
212
|
+
endpoint = f"addClient"
|
|
213
|
+
if isinstance(client, Dict):
|
|
214
|
+
try:
|
|
215
|
+
client = str(client)
|
|
216
|
+
final = models.InboundClients.model_validate_json(client)
|
|
217
|
+
except ValidationError:
|
|
218
|
+
# if there is in fact an error, I want it to raise
|
|
219
|
+
tmp = models.SingleInboundClient.model_validate_json(client)
|
|
220
|
+
if inbound_id:
|
|
221
|
+
final = models.InboundClients(id=inbound_id,
|
|
222
|
+
settings=models.InboundClients.Settings(clients=[tmp]))
|
|
223
|
+
else:
|
|
224
|
+
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
225
|
+
elif isinstance(client, models.SingleInboundClient):
|
|
226
|
+
final = models.InboundClients(id=inbound_id,
|
|
227
|
+
settings=models.InboundClients.Settings(clients=[client]))
|
|
228
|
+
elif isinstance(client, models.InboundClients):
|
|
229
|
+
final = client
|
|
230
|
+
if inbound_id:
|
|
231
|
+
final.parent_id = inbound_id
|
|
232
|
+
else:
|
|
233
|
+
raise TypeError
|
|
234
|
+
# send request
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
data = final.model_dump(by_alias=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
resp = await self.client.safe_post(f"{self._url}{endpoint}", data=data)
|
|
242
|
+
|
|
243
|
+
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU FUCKING DUMBASS!
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
return resp
|
|
247
|
+
|
|
248
|
+
async def _request_update_client(self, client: models.InboundClients | models.SingleInboundClient,
|
|
249
|
+
inbound_id: int | None = None,
|
|
250
|
+
*, original_uuid: str | None = None) -> Response:
|
|
251
|
+
"""Request to update an existing client.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
client: The client data to update. Can be:
|
|
255
|
+
- A SingleInboundClient (requires inbound_id)
|
|
256
|
+
- An InboundClients object (with one client)
|
|
257
|
+
inbound_id: The ID of the inbound the client belongs to.
|
|
258
|
+
Required if client is a SingleInboundClient.
|
|
259
|
+
original_uuid: The original UUID of the client to update.
|
|
260
|
+
Required if client is a SingleInboundClient.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The HTTP response from the API.
|
|
264
|
+
"""
|
|
265
|
+
if isinstance(client, models.SingleInboundClient):
|
|
266
|
+
if inbound_id is None:
|
|
267
|
+
raise ValueError("Provide a parent inbound ID or pass models.InboundClients")
|
|
268
|
+
client = models.InboundClients(parent_id=inbound_id,
|
|
269
|
+
settings=models.InboundClients.Settings(clients=[client]))
|
|
270
|
+
else:
|
|
271
|
+
if len(client.settings.clients) != 1:
|
|
272
|
+
raise ValueError(f"You can only update 1 client at a time, instead got {len(client.settings.clients)}")
|
|
273
|
+
|
|
274
|
+
_endpoint = f"updateClient/{original_uuid if original_uuid else client.settings.clients[0].uuid}"
|
|
275
|
+
resp = await self.client.safe_post(f"{self._url}{_endpoint}", json=client.model_dump_json())
|
|
276
|
+
|
|
277
|
+
return resp
|
|
278
|
+
|
|
279
|
+
async def update_single_client(self, existing_client: SingleInboundClient, inbound_id: int, /, *,
|
|
280
|
+
security: str | None = None,
|
|
281
|
+
password: str | None = None,
|
|
282
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
283
|
+
email: str | None = None,
|
|
284
|
+
limit_ip: int | None = None,
|
|
285
|
+
limit_gb: int | None = None,
|
|
286
|
+
expiry_time: models.timestamp | None = None,
|
|
287
|
+
enable: bool | None = None,
|
|
288
|
+
sub_id: str | None = None,
|
|
289
|
+
comment: str | None = None,
|
|
290
|
+
):
|
|
291
|
+
"""Update an existing client's details.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
existing_client: The existing client object to update.
|
|
295
|
+
inbound_id: The ID of the inbound the client belongs to.
|
|
296
|
+
security: New security settings (optional).
|
|
297
|
+
password: New password (optional).
|
|
298
|
+
flow: New flow settings (optional).
|
|
299
|
+
email: New email address (optional).
|
|
300
|
+
limit_ip: New IP limit (optional).
|
|
301
|
+
limit_gb: New GB limit (optional).
|
|
302
|
+
expiry_time: New expiry time (optional).
|
|
303
|
+
enable: New enable status (optional).
|
|
304
|
+
sub_id: New subscription ID (optional).
|
|
305
|
+
comment: New comment (optional).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The HTTP response from the API.
|
|
309
|
+
"""
|
|
310
|
+
# Collect only the arguments that were explicitly provided (not None)
|
|
311
|
+
changes = {k: v for k, v in locals().items()
|
|
312
|
+
if k != 'self' and k != 'existing_client' and v is not None}
|
|
313
|
+
# Rename sub_id to subscription_id if needed
|
|
314
|
+
if 'sub_id' in changes:
|
|
315
|
+
changes['subscription_id'] = changes.pop('sub_id')
|
|
316
|
+
changes["updated_at"] = int(datetime.now(UTC).timestamp())
|
|
317
|
+
updated = existing_client.model_copy(update=changes)
|
|
318
|
+
|
|
319
|
+
resp = await self._request_update_client(updated, inbound_id)
|
|
320
|
+
return resp
|
|
321
|
+
|
|
322
|
+
async def delete_expired_clients(self, inbound_id: int) -> Response:
|
|
323
|
+
"""Delete expired clients from an inbound.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
inbound_id: The ID of the inbound to delete expired clients from.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The HTTP response from the API.
|
|
330
|
+
"""
|
|
331
|
+
_endpoint = f"delDepletedClients/"
|
|
332
|
+
resp = await self.client.safe_post(f"{self._url}{_endpoint}{inbound_id}")
|
|
333
|
+
return resp
|
|
334
|
+
|
|
335
|
+
async def delete_client_by_email(self, email: str, inbound_id: int) -> Response:
|
|
336
|
+
"""Delete a client by email.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
email: The email of the client to delete.
|
|
340
|
+
inbound_id: The ID of the inbound the client belongs to.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The HTTP response from the API.
|
|
344
|
+
"""
|
|
345
|
+
_endpoint = f"{inbound_id}/delClient/{email}"
|
|
346
|
+
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
347
|
+
return resp
|
|
348
|
+
|
|
349
|
+
async def delete_client_by_uuid(self, uuid: str, inbound_id: int) -> Response:
|
|
350
|
+
"""Delete a client by UUID.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
uuid: The UUID of the client to delete.
|
|
354
|
+
inbound_id: The ID of the inbound the client belongs to.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
The HTTP response from the API.
|
|
358
|
+
"""
|
|
359
|
+
_endpoint = f"{inbound_id}/delClient/{uuid}"
|
|
360
|
+
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
361
|
+
return resp
|
python_3xui/models.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from types import NoneType
|
|
3
|
+
from datetime import datetime, UTC
|
|
4
|
+
from typing import Union, Optional, TypeAlias, Any, Annotated, Literal, List, Dict
|
|
5
|
+
|
|
6
|
+
from pydantic import field_validator, Field, field_serializer
|
|
7
|
+
import pydantic
|
|
8
|
+
|
|
9
|
+
from . import base_model
|
|
10
|
+
from .util import JsonType
|
|
11
|
+
|
|
12
|
+
timestamp: TypeAlias = int
|
|
13
|
+
ip_address: TypeAlias = str
|
|
14
|
+
json_string: TypeAlias = str
|
|
15
|
+
|
|
16
|
+
def exclude_if_none(field) -> bool:
|
|
17
|
+
"""Check if a field value is None for exclusion purposes.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
field: The field value to check.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if the field is None, False otherwise.
|
|
24
|
+
"""
|
|
25
|
+
if field is None:
|
|
26
|
+
return True
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
class SingleInboundClient(pydantic.BaseModel):
|
|
30
|
+
"""Represents a single client within a VLESS/VMess inbound.
|
|
31
|
+
|
|
32
|
+
This model represents an individual VPN client with all its configuration
|
|
33
|
+
settings including traffic limits, expiry, and authentication details.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
uuid: The unique identifier for the client (aliased as 'id' in API).
|
|
37
|
+
security: The security protocol used by the client.
|
|
38
|
+
password: The client's password for authentication.
|
|
39
|
+
flow: The VLESS flow type controlling connection behavior.
|
|
40
|
+
email: The client's email identifier.
|
|
41
|
+
limit_ip: Maximum number of simultaneous IP connections.
|
|
42
|
+
limit_gb: Total data limit in gigabytes.
|
|
43
|
+
expiry_time: Client expiry time as UNIX timestamp (0 = no expiry).
|
|
44
|
+
enable: Whether the client is enabled.
|
|
45
|
+
tg_id: Associated Telegram ID for notifications.
|
|
46
|
+
subscription_id: Subscription identifier for URL generation.
|
|
47
|
+
comment: Admin notes or comments for the client.
|
|
48
|
+
created_at: Timestamp of client creation.
|
|
49
|
+
updated_at: Timestamp of last client update.
|
|
50
|
+
"""
|
|
51
|
+
uuid: Annotated[str, Field(alias="id")] #yes they really did that...
|
|
52
|
+
security: str = ""
|
|
53
|
+
password: str = ""
|
|
54
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
|
|
55
|
+
email: Annotated[str, Field(alias="email")]
|
|
56
|
+
limit_ip: Annotated[int, Field(alias="limitIp")] = 20
|
|
57
|
+
limit_gb: Annotated[int, Field(alias="totalGB")] # total flow
|
|
58
|
+
expiry_time: Annotated[timestamp, Field(alias="expiryTime")] = 0
|
|
59
|
+
enable: bool = True
|
|
60
|
+
tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
|
|
61
|
+
subscription_id: Annotated[str, Field(alias="subId")]
|
|
62
|
+
comment: str = ""
|
|
63
|
+
created_at: Annotated[timestamp, Field(default_factory=(lambda: int(datetime.now(UTC).timestamp())))]
|
|
64
|
+
updated_at: Annotated[timestamp, Field(default_factory=(lambda: int(datetime.now(UTC).timestamp())))]
|
|
65
|
+
|
|
66
|
+
class InboundClients(pydantic.BaseModel):
|
|
67
|
+
"""Represents a collection of clients for an inbound connection.
|
|
68
|
+
|
|
69
|
+
This model is used when adding or updating clients on an inbound,
|
|
70
|
+
containing the parent inbound ID and the list of clients.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
parent_id: The ID of the parent inbound (aliased as 'id').
|
|
74
|
+
settings: The settings object containing the client list.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
class Settings(pydantic.BaseModel):
|
|
78
|
+
"""Settings container for inbound clients.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
clients: List of SingleInboundClient objects.
|
|
82
|
+
"""
|
|
83
|
+
clients: list[SingleInboundClient]
|
|
84
|
+
|
|
85
|
+
parent_id: Annotated[int|None, Field(exclude_if=exclude_if_none, alias="id")] = None
|
|
86
|
+
settings: Settings
|
|
87
|
+
|
|
88
|
+
@field_serializer("settings")
|
|
89
|
+
def stringify_settings(self, value: Settings) -> str:
|
|
90
|
+
"""Serialize the settings object to a JSON string.
|
|
91
|
+
|
|
92
|
+
The 3X-UI API expects settings as a JSON string, not an object.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
value: The Settings object to serialize.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A JSON string representation of the settings.
|
|
99
|
+
"""
|
|
100
|
+
return json.dumps(value.model_dump(by_alias=True), ensure_ascii=False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#class InboundSettings(base_model.BaseModel):
|
|
104
|
+
# clients: list[Clients]
|
|
105
|
+
# decryption: str
|
|
106
|
+
# encryption: str
|
|
107
|
+
# selectedAuth: Annotated[Union[str|None], Field(exclude_if=exclude_if_none)] = None # "X25519, not Post-Quantum"
|
|
108
|
+
#
|
|
109
|
+
# "StreamSettings Stuff"
|
|
110
|
+
#
|
|
111
|
+
# class ExternalProxy(base_model.BaseModel):
|
|
112
|
+
# force_tls: Annotated[str, Field(alias="ForceTls")]
|
|
113
|
+
# dest: str
|
|
114
|
+
# port: int
|
|
115
|
+
# remark: str
|
|
116
|
+
#
|
|
117
|
+
# class StreamRequest(base_model.BaseModel):
|
|
118
|
+
# version: str
|
|
119
|
+
# method: str
|
|
120
|
+
# path: List[str]
|
|
121
|
+
# headers: dict[str, str|int]
|
|
122
|
+
#
|
|
123
|
+
# class StreamResponse(base_model.BaseModel):
|
|
124
|
+
# version: str
|
|
125
|
+
# status: int
|
|
126
|
+
# reason: str
|
|
127
|
+
# headers: dict[str, str|int]
|
|
128
|
+
#
|
|
129
|
+
# class TCPSettingsHeader(base_model.BaseModel):
|
|
130
|
+
# type: str
|
|
131
|
+
# request: Annotated[Optional[StreamRequest], Field(exclude_if=exclude_if_none)] = None
|
|
132
|
+
# response: Annotated[Optional[StreamRequest], Field(exclude_if=exclude_if_none)] = None
|
|
133
|
+
#
|
|
134
|
+
# class TCPSettings(base_model.BaseModel):
|
|
135
|
+
# accept_proxy_protocol: Annotated[bool, Field(alias="acceptProxyProtocol")]
|
|
136
|
+
#
|
|
137
|
+
# class SockOpt(base_model.BaseModel):
|
|
138
|
+
# acceptProxyProtocol: bool
|
|
139
|
+
# tcpFastOpen: bool
|
|
140
|
+
# mark: int
|
|
141
|
+
# tproxy: str
|
|
142
|
+
# tcpMptcp: bool
|
|
143
|
+
# penetrate: bool
|
|
144
|
+
# domainStrategy: str
|
|
145
|
+
# tcpMaxSeg: int
|
|
146
|
+
# dialerProxy: str
|
|
147
|
+
# tcpKeepAliveInterval: int
|
|
148
|
+
# tcpKeepAliveIdle: int
|
|
149
|
+
# tcpUserTimeout: int
|
|
150
|
+
# tcpcongestion: str
|
|
151
|
+
# V6Only: bool
|
|
152
|
+
# tcpWindowClamp: int
|
|
153
|
+
# interface: str
|
|
154
|
+
#
|
|
155
|
+
# class StreamSettings(base_model.BaseModel):
|
|
156
|
+
# network_type: Annotated[str, Field(alias="network")]
|
|
157
|
+
# security: str # none, reality, TLS
|
|
158
|
+
# external_proxy: Annotated[list[ExternalProxy], Field(alias="externalProxy")]
|
|
159
|
+
# tcp_settings: TCPSettings
|
|
160
|
+
|
|
161
|
+
class ClientStats(base_model.BaseModel):
|
|
162
|
+
"""Statistics and configuration for a VPN client.
|
|
163
|
+
|
|
164
|
+
This model represents client statistics returned by the 3X-UI API,
|
|
165
|
+
including traffic usage, expiry information, and connection details.
|
|
166
|
+
|
|
167
|
+
Attributes:
|
|
168
|
+
id: Internal database ID of the client record.
|
|
169
|
+
inboundId: The ID of the inbound this client belongs to.
|
|
170
|
+
enable: Whether the client is currently enabled.
|
|
171
|
+
email: The client's email identifier.
|
|
172
|
+
uuid: The client's unique identifier.
|
|
173
|
+
subId: The subscription ID for URL generation.
|
|
174
|
+
up: Total uploaded bytes.
|
|
175
|
+
down: Total downloaded bytes.
|
|
176
|
+
allTime: Total bytes transferred (up + down).
|
|
177
|
+
expiryTime: Client expiry time as UNIX timestamp.
|
|
178
|
+
total: Total data limit in bytes.
|
|
179
|
+
reset: Counter for traffic resets.
|
|
180
|
+
lastOnline: UNIX timestamp of last connection.
|
|
181
|
+
"""
|
|
182
|
+
id: int
|
|
183
|
+
inboundId: int
|
|
184
|
+
enable: bool
|
|
185
|
+
email: str
|
|
186
|
+
uuid: str
|
|
187
|
+
subId: str
|
|
188
|
+
up: int # bytes
|
|
189
|
+
down: int # bytes
|
|
190
|
+
allTime: int # bytes
|
|
191
|
+
expiryTime: timestamp # UNIX timestamp
|
|
192
|
+
total: int
|
|
193
|
+
reset: int
|
|
194
|
+
lastOnline: timestamp
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Inbound(base_model.BaseModel):
|
|
198
|
+
"""Represents a VPN inbound connection configuration.
|
|
199
|
+
|
|
200
|
+
An inbound defines how VPN clients connect to the server, including
|
|
201
|
+
the protocol, port, traffic statistics, and client list.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
id: The unique identifier for this inbound.
|
|
205
|
+
up: Total uploaded bytes through this inbound.
|
|
206
|
+
down: Total downloaded bytes through this inbound.
|
|
207
|
+
total: Total data limit in bytes.
|
|
208
|
+
allTime: Total bytes transferred (up + down).
|
|
209
|
+
remark: Human-readable name/description for the inbound.
|
|
210
|
+
enable: Whether the inbound is currently enabled.
|
|
211
|
+
expiryTime: Inbound expiry time as UNIX timestamp.
|
|
212
|
+
trafficReset: Traffic reset schedule ("Never", "Weekly", "Monthly", "Daily").
|
|
213
|
+
lastTrafficResetTime: UNIX timestamp of last traffic reset.
|
|
214
|
+
clientStats: List of client statistics, or None if no clients.
|
|
215
|
+
listen: The IP address the inbound listens on.
|
|
216
|
+
port: The port number the inbound listens on.
|
|
217
|
+
protocol: The VPN protocol (vless, vmess, trojan, shadowsocks, wireguard).
|
|
218
|
+
settings: JSON configuration for the inbound (auto-parsed from string).
|
|
219
|
+
streamSettings: JSON stream configuration (auto-parsed from string).
|
|
220
|
+
tag: Internal tag identifier for routing.
|
|
221
|
+
sniffing: JSON sniffing configuration (auto-parsed from string).
|
|
222
|
+
"""
|
|
223
|
+
id: int
|
|
224
|
+
up: int # bytes
|
|
225
|
+
down: int # bytes
|
|
226
|
+
total: int # bytes
|
|
227
|
+
allTime: int # bytes
|
|
228
|
+
remark: str
|
|
229
|
+
enable: bool
|
|
230
|
+
expiryTime: timestamp # UNIX timestamp
|
|
231
|
+
trafficReset: str # "Never", "Weekly", "Monthly", "Daily"
|
|
232
|
+
lastTrafficResetTime: timestamp # UNIX timestamp
|
|
233
|
+
clientStats: Union[list[ClientStats], None]
|
|
234
|
+
listen: str
|
|
235
|
+
port: int
|
|
236
|
+
protocol: Literal["vless", "vmess", "trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
|
|
237
|
+
settings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
238
|
+
streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
239
|
+
tag: str
|
|
240
|
+
sniffing: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
241
|
+
|
|
242
|
+
# noinspection PyNestedDecorators
|
|
243
|
+
@field_validator('settings', 'streamSettings', 'sniffing', mode='after')
|
|
244
|
+
@classmethod
|
|
245
|
+
def parse_json_fields(cls, value: str) -> JsonType|Literal[""]:
|
|
246
|
+
"""Parse JSON string fields into dictionaries.
|
|
247
|
+
|
|
248
|
+
The 3X-UI API returns settings, streamSettings, and sniffing as
|
|
249
|
+
JSON strings. This validator automatically parses them into dicts.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
value: The JSON string to parse, or empty string.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Parsed dictionary, or empty string if input was empty.
|
|
256
|
+
"""
|
|
257
|
+
if value == "":
|
|
258
|
+
return ""
|
|
259
|
+
return json.loads(value)
|
|
260
|
+
|
|
261
|
+
# noinspection PyNestedDecorators
|
|
262
|
+
@field_serializer("settings", "streamSettings", "sniffing")
|
|
263
|
+
@classmethod
|
|
264
|
+
def stringify_json_fields(cls, value: Dict|Literal[""]) -> str:
|
|
265
|
+
"""Serialize dictionary fields back to JSON strings.
|
|
266
|
+
|
|
267
|
+
When sending data back to the API, these fields must be JSON strings.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
value: The dictionary to serialize, or empty string.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
JSON string representation, or empty string if input was empty.
|
|
274
|
+
"""
|
|
275
|
+
if value == "":
|
|
276
|
+
return ""
|
|
277
|
+
return json.dumps(value, ensure_ascii=False)
|