Python-3xui 0.0.9__tar.gz → 0.0.9.post2__tar.gz
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-0.0.9 → python_3xui-0.0.9.post2}/PKG-INFO +1 -2
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/pyproject.toml +1 -2
- python_3xui-0.0.9.post2/python_3xui/__init__.py +9 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/api.py +206 -81
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/base_model.py +9 -6
- python_3xui-0.0.9.post2/python_3xui/custom_exceptions.py +27 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/endpoints.py +33 -28
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/models.py +31 -21
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/util.py +41 -12
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/conftest.py +1 -1
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_non_idempotent_endpoints_clients.py +1 -1
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_xuiclient_helpers.py +1 -1
- python_3xui-0.0.9/python_3xui/__init__.py +0 -7
- python_3xui-0.0.9/python_3xui/custom_exceptions.py +0 -12
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/.gitignore +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/LICENSE +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/README.md +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/pytest.ini +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Python-3xui
|
|
3
|
-
Version: 0.0.9
|
|
3
|
+
Version: 0.0.9.post2
|
|
4
4
|
Summary: 3x-ui wrapper for python
|
|
5
5
|
Project-URL: Homepage, https://github.com/Artem-Potapov/3x-py
|
|
6
6
|
Project-URL: Issues, https://github.com/Artem-Potapov/3x-py/issues
|
|
@@ -17,7 +17,6 @@ Requires-Dist: dotenv~=0.9.9
|
|
|
17
17
|
Requires-Dist: httpx~=0.28.1
|
|
18
18
|
Requires-Dist: pydantic<3,~=2.12.5
|
|
19
19
|
Requires-Dist: pyotp~=2.9.0
|
|
20
|
-
Requires-Dist: python-dotenv
|
|
21
20
|
Provides-Extra: testing
|
|
22
21
|
Requires-Dist: pytest; extra == 'testing'
|
|
23
22
|
Requires-Dist: pytest-asyncio; extra == 'testing'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "Python-3xui"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.9r2"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
6
|
]
|
|
@@ -23,7 +23,6 @@ dependencies = [
|
|
|
23
23
|
"pydantic ~= 2.12.5, < 3",
|
|
24
24
|
"httpx ~=0.28.1",
|
|
25
25
|
"dotenv ~= 0.9.9",
|
|
26
|
-
"python-dotenv",
|
|
27
26
|
"async_lru ~= 2.3.0",
|
|
28
27
|
"pyotp ~= 2.9.0"
|
|
29
28
|
]
|
|
@@ -8,6 +8,7 @@ from datetime import datetime, UTC
|
|
|
8
8
|
from inspect import iscoroutinefunction
|
|
9
9
|
from logging import DEBUG
|
|
10
10
|
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
|
|
11
|
+
import contextlib
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
14
|
import pyotp
|
|
@@ -17,19 +18,20 @@ from pydantic import SecretStr
|
|
|
17
18
|
|
|
18
19
|
from . import custom_exceptions
|
|
19
20
|
from . import util
|
|
21
|
+
from . import endpoints
|
|
20
22
|
from .models import Inbound, SingleInboundClient, ClientStats
|
|
21
|
-
from .util import JsonType, async_range
|
|
23
|
+
from .util import JsonType, async_range, get_inbound_in_client
|
|
22
24
|
|
|
23
25
|
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
|
24
26
|
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
25
27
|
ParamType = Union[
|
|
26
28
|
Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
list[Tuple[str, PrimitiveData]],
|
|
30
|
+
tuple[Tuple[str, PrimitiveData], ...],
|
|
29
31
|
str,
|
|
30
32
|
bytes,
|
|
31
33
|
]
|
|
32
|
-
CookieType = Union[Dict[str, str],
|
|
34
|
+
CookieType = Union[Dict[str, str], list[tuple[str, str]]]
|
|
33
35
|
HeaderType = Union[
|
|
34
36
|
Mapping[str, str],
|
|
35
37
|
Mapping[bytes, bytes],
|
|
@@ -43,21 +45,26 @@ class XUIClient:
|
|
|
43
45
|
|
|
44
46
|
This class provides methods for authenticating with the 3X-UI panel,
|
|
45
47
|
managing sessions, and performing operations on inbounds and clients.
|
|
48
|
+
It also owns the endpoint handlers and the per-instance production
|
|
49
|
+
inbound cache.
|
|
46
50
|
|
|
47
51
|
Attributes:
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
connected: Whether an HTTP session is currently open.
|
|
53
|
+
PROD_STRING: Compiled regex used to identify production inbounds.
|
|
54
|
+
session: The async HTTP client session, if connected.
|
|
50
55
|
base_host: The server hostname.
|
|
51
56
|
base_port: The server port.
|
|
52
57
|
base_path: The base path for the API.
|
|
53
58
|
base_url: The full base URL for API requests.
|
|
54
59
|
session_start: Timestamp of when the session was created.
|
|
55
60
|
session_duration: Maximum session duration in seconds.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
xui_username: Username for authentication.
|
|
62
|
+
xui_password: Password for authentication.
|
|
63
|
+
two_fac_secret: TOTP secret or one-shot 2FA code, if configured.
|
|
64
|
+
totp: TOTP generator used for repeated logins when a secret is provided.
|
|
59
65
|
max_retries: Maximum number of retry attempts for failed requests.
|
|
60
66
|
retry_delay: Delay in seconds between retries.
|
|
67
|
+
sub_gen: Callable used to derive subscription IDs from Telegram IDs.
|
|
61
68
|
server_end: Server endpoint handler.
|
|
62
69
|
clients_end: Clients endpoint handler.
|
|
63
70
|
inbounds_end: Inbounds endpoint handler.
|
|
@@ -67,8 +74,8 @@ class XUIClient:
|
|
|
67
74
|
*, username: str | None = None, password: str | None = None,
|
|
68
75
|
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
69
76
|
custom_prod_string: str = "testing",
|
|
70
|
-
max_retries: int = 5, retry_delay
|
|
71
|
-
custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
77
|
+
max_retries: int = 5, retry_delay=1,
|
|
78
|
+
custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
72
79
|
) -> None:
|
|
73
80
|
"""Initialize the XUIClient.
|
|
74
81
|
|
|
@@ -78,10 +85,15 @@ class XUIClient:
|
|
|
78
85
|
base_path: The base path for the API (e.g., "/panel").
|
|
79
86
|
username: Username for authentication.
|
|
80
87
|
password: Password for authentication.
|
|
81
|
-
two_fac_code:
|
|
88
|
+
two_fac_code: TOTP secret for 2FA. Short one-shot codes are
|
|
89
|
+
accepted for the current login only.
|
|
82
90
|
session_duration: Maximum session duration in seconds. Defaults to 3600.
|
|
91
|
+
custom_prod_string: Regex pattern used to select production inbounds.
|
|
92
|
+
max_retries: Maximum retries for database-lock responses.
|
|
93
|
+
retry_delay: Seconds to wait between database-lock retries.
|
|
94
|
+
custom_sub_generator: Sync or async callable that receives a
|
|
95
|
+
Telegram ID and returns the subscription ID for new clients.
|
|
83
96
|
"""
|
|
84
|
-
from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
|
|
85
97
|
self.connected: bool = False
|
|
86
98
|
self.PROD_STRING = re.compile(custom_prod_string)
|
|
87
99
|
self.session: AsyncClient | None = None
|
|
@@ -107,7 +119,7 @@ class XUIClient:
|
|
|
107
119
|
# a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
|
|
108
120
|
# instance its own cache bound to its own loop.
|
|
109
121
|
self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
|
|
110
|
-
self._cache_cleaner_task: Task|None = None
|
|
122
|
+
self._cache_cleaner_task: Task | None = None
|
|
111
123
|
#init self.totp
|
|
112
124
|
if self.two_fac_secret:
|
|
113
125
|
if len(self.two_fac_secret.get_secret_value()) <= 8:
|
|
@@ -129,33 +141,52 @@ class XUIClient:
|
|
|
129
141
|
...
|
|
130
142
|
|
|
131
143
|
async def _safe_request(self,
|
|
132
|
-
method: Literal["get", "post", "patch", "delete", "put"]|None=None,
|
|
144
|
+
method: Literal["get", "post", "patch", "delete", "put"] | None = None,
|
|
133
145
|
**kwargs) -> Response:
|
|
134
146
|
"""Execute an HTTP request with automatic retry on database lock.
|
|
135
147
|
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
The request can be made either from a prebuilt ``request_to_send`` or
|
|
149
|
+
from an HTTP method plus keyword arguments accepted by ``httpx``.
|
|
150
|
+
The method handles automatic session refresh on expired 404 responses
|
|
151
|
+
and retries when the 3X-UI database is locked.
|
|
138
152
|
|
|
139
153
|
Args:
|
|
140
|
-
method: The HTTP method to use.
|
|
141
|
-
**kwargs:
|
|
154
|
+
method: The HTTP method to use when building a new request.
|
|
155
|
+
**kwargs: Either ``request_to_send`` by itself, or request
|
|
156
|
+
arguments such as ``url``, ``json``, ``params``, and headers.
|
|
142
157
|
|
|
143
158
|
Returns:
|
|
144
159
|
The HTTP response.
|
|
145
160
|
|
|
146
161
|
Raises:
|
|
147
|
-
|
|
162
|
+
ValueError: If neither a method nor a prebuilt request is provided,
|
|
163
|
+
or both request styles are mixed.
|
|
164
|
+
RuntimeError: If max retries are exceeded or a valid session gets
|
|
165
|
+
an unexpected 404 response.
|
|
148
166
|
"""
|
|
149
167
|
if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
|
|
150
|
-
raise ValueError("
|
|
168
|
+
raise ValueError("Provide either a prebuilt request or arguments to build one.")
|
|
151
169
|
if not "request_to_send" in kwargs:
|
|
152
170
|
if method is None:
|
|
153
171
|
raise ValueError("If there's no prebuilt request, you must provide a method.")
|
|
154
172
|
|
|
155
|
-
|
|
173
|
+
url = kwargs["url"] if "url" in kwargs.keys() else kwargs["request_to_send"].url
|
|
174
|
+
if "json" in kwargs:
|
|
175
|
+
json_payload = kwargs["json"]
|
|
176
|
+
elif "request_to_send" in kwargs:
|
|
177
|
+
_req = kwargs["request_to_send"]
|
|
178
|
+
if _req.content:
|
|
179
|
+
try:
|
|
180
|
+
json_payload = json.loads(_req.content.decode())
|
|
181
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
182
|
+
json_payload = None
|
|
183
|
+
else:
|
|
184
|
+
json_payload = None
|
|
185
|
+
else:
|
|
186
|
+
json_payload = None
|
|
156
187
|
logging.info("Safe %s is running to %s%s\nJSON Payload: %s",
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
method, str(self.session.base_url), str(url),
|
|
189
|
+
json.dumps(json_payload) if json_payload is not None else "(no payload)")
|
|
159
190
|
async for attempt in async_range(self.max_retries):
|
|
160
191
|
if "request_to_send" in kwargs:
|
|
161
192
|
_request: Request = kwargs["request_to_send"]
|
|
@@ -295,11 +326,12 @@ class XUIClient:
|
|
|
295
326
|
resp = await self.session.post("/login", data=payload)
|
|
296
327
|
if resp.status_code == 200:
|
|
297
328
|
resp_json = resp.json()
|
|
298
|
-
if
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
else:
|
|
329
|
+
if "success" not in resp_json:
|
|
330
|
+
raise RuntimeError(f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}")
|
|
331
|
+
if not resp_json["success"]:
|
|
302
332
|
raise ValueError("Error: wrong credentials (including status code) or failed login.")
|
|
333
|
+
self.session_start: float = (datetime.now(UTC).timestamp())
|
|
334
|
+
return
|
|
303
335
|
else:
|
|
304
336
|
raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
|
|
305
337
|
|
|
@@ -322,7 +354,8 @@ class XUIClient:
|
|
|
322
354
|
This method closes the async HTTP client session.
|
|
323
355
|
"""
|
|
324
356
|
if self._cache_cleaner_task is not None:
|
|
325
|
-
|
|
357
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
358
|
+
self._cache_cleaner_task.cancel("Panel is exiting.")
|
|
326
359
|
self.connected = False
|
|
327
360
|
|
|
328
361
|
if self.session is not None:
|
|
@@ -341,7 +374,7 @@ class XUIClient:
|
|
|
341
374
|
self.connect()
|
|
342
375
|
await self.login()
|
|
343
376
|
self._cache_cleaner_task = asyncio.create_task(
|
|
344
|
-
self.
|
|
377
|
+
self._clear_prod_inbound_cache_task(), name=f"inb_cache_clearer_for_{self.base_url}"
|
|
345
378
|
)
|
|
346
379
|
return self
|
|
347
380
|
|
|
@@ -356,7 +389,7 @@ class XUIClient:
|
|
|
356
389
|
exc_val: The exception value, if an exception occurred.
|
|
357
390
|
exc_tb: The exception traceback, if an exception occurred.
|
|
358
391
|
"""
|
|
359
|
-
if exc_type is None or exc_type
|
|
392
|
+
if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
|
|
360
393
|
logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
|
|
361
394
|
else:
|
|
362
395
|
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
@@ -391,24 +424,23 @@ class XUIClient:
|
|
|
391
424
|
|
|
392
425
|
return tuple(usable_inbounds)
|
|
393
426
|
|
|
394
|
-
async def
|
|
395
|
-
"""
|
|
427
|
+
async def _clear_prod_inbound_cache_task(self):
|
|
428
|
+
"""Refresh the production inbound cache in the background.
|
|
396
429
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
Note:
|
|
402
|
-
This method currently runs every 10 seconds. Please change the
|
|
403
|
-
timer from 5 to 60*60*24 in the code.
|
|
430
|
+
The async context manager starts this loop after login. Each cycle
|
|
431
|
+
clears the cached production inbound list, repopulates it from the
|
|
432
|
+
panel, and then waits before refreshing again.
|
|
404
433
|
"""
|
|
434
|
+
if self._cache_cleaner_task is not None:
|
|
435
|
+
logging.warning("You're trying to create another cache cleaner task, which is a FaF (Fire-And-Forget)."
|
|
436
|
+
"Please destroy the previous task and set _cache_cleaner_task to None, if you know what you're doing.")
|
|
405
437
|
while self.connected:
|
|
406
438
|
self.get_production_inbounds.cache_clear()
|
|
407
|
-
await self.get_production_inbounds() #fill the cache
|
|
408
|
-
await asyncio.sleep(3600) #update every 1h
|
|
439
|
+
await self.get_production_inbounds() # fill the cache
|
|
440
|
+
await asyncio.sleep(3600) # update every 1h
|
|
409
441
|
|
|
410
442
|
#========================clients management========================
|
|
411
|
-
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) ->
|
|
443
|
+
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
|
|
412
444
|
"""Retrieve client information by Telegram ID.
|
|
413
445
|
|
|
414
446
|
This method fetches client information using the Telegram ID. If
|
|
@@ -436,25 +468,31 @@ class XUIClient:
|
|
|
436
468
|
|
|
437
469
|
async def create_and_add_prod_client(self, telegram_id: int, *,
|
|
438
470
|
additional_remark: str | None = None,
|
|
439
|
-
expiry_time: int=0,
|
|
471
|
+
expiry_time: int = 0,
|
|
440
472
|
exist_ok: bool = False
|
|
441
473
|
) -> list[Response]:
|
|
442
474
|
"""Create and add a production client.
|
|
443
475
|
|
|
444
476
|
This method creates a new client with the given Telegram ID and
|
|
445
477
|
adds it to the production inbounds. The client is configured with
|
|
446
|
-
default settings and the additional remark.
|
|
447
|
-
|
|
478
|
+
default settings and the additional remark. The subscription ID is
|
|
479
|
+
created by ``self.sub_gen``; by default this is
|
|
480
|
+
``util.default_sub_from_tgid``.
|
|
448
481
|
|
|
449
482
|
Args:
|
|
450
483
|
telegram_id: The Telegram ID of the client.
|
|
451
484
|
additional_remark: An optional additional remark for the client.
|
|
452
485
|
expiry_time: Expiry time in SECONDS as a UNIX timestamp.
|
|
453
|
-
exist_ok:
|
|
486
|
+
exist_ok: If True, return API responses even when the panel reports
|
|
487
|
+
a duplicate email.
|
|
454
488
|
|
|
455
489
|
Returns:
|
|
456
490
|
List[Response]: A list of responses from the server for each
|
|
457
491
|
inbound the client was added to.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
ClientEmailAlreadyExistsError: If a duplicate client is reported
|
|
495
|
+
and ``exist_ok`` is False.
|
|
458
496
|
"""
|
|
459
497
|
production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
|
|
460
498
|
|
|
@@ -466,15 +504,15 @@ class XUIClient:
|
|
|
466
504
|
custom_sub = self.sub_gen(telegram_id)
|
|
467
505
|
for inb in production_inbounds:
|
|
468
506
|
tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
|
|
469
|
-
client = SingleInboundClient
|
|
507
|
+
client = SingleInboundClient(
|
|
470
508
|
uuid=util.get_uuid_from_tgid(telegram_id),
|
|
471
509
|
flow="",
|
|
472
510
|
email=tmp_email,
|
|
473
511
|
limit_gb=0,
|
|
474
512
|
enable=True,
|
|
475
513
|
subscription_id=custom_sub,
|
|
476
|
-
comment=f"{additional_remark
|
|
477
|
-
expiry_time=expiry_time * 1000
|
|
514
|
+
comment=f"{additional_remark + ", " if additional_remark else ""}created at {datetime.now(UTC)}",
|
|
515
|
+
expiry_time=expiry_time * 1000,
|
|
478
516
|
)
|
|
479
517
|
tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
|
|
480
518
|
responses: list[Response] = await asyncio.gather(*tasks)
|
|
@@ -487,19 +525,28 @@ class XUIClient:
|
|
|
487
525
|
raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
|
|
488
526
|
return responses
|
|
489
527
|
|
|
490
|
-
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int) -> SingleInboundClient|None:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
528
|
+
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int, use_cache=False) -> SingleInboundClient | None:
|
|
529
|
+
"""Note:
|
|
530
|
+
Cached production inbounds can be stale because the panel may be
|
|
531
|
+
changed by another actor. If a cached production inbound misses the
|
|
532
|
+
client, the production cache is cleared and fetched once more
|
|
533
|
+
before falling back to a direct inbound lookup.
|
|
534
|
+
"""
|
|
535
|
+
if use_cache:
|
|
536
|
+
prod_inbs = await self.get_production_inbounds()
|
|
537
|
+
prod_inb_index = None
|
|
538
|
+
for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
|
|
539
|
+
if inbound_id == prod_inb.id:
|
|
540
|
+
prod_inb_index = i
|
|
541
|
+
|
|
542
|
+
if prod_inb_index is not None:
|
|
543
|
+
needed_inb: Inbound = prod_inbs[prod_inb_index]
|
|
544
|
+
result = get_inbound_in_client(client_uuid, needed_inb)
|
|
545
|
+
if result is None:
|
|
546
|
+
self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
|
|
547
|
+
new_inb = (await self.get_production_inbounds())[prod_inb_index]
|
|
548
|
+
new_result = get_inbound_in_client(client_uuid, new_inb)
|
|
549
|
+
return new_result
|
|
503
550
|
|
|
504
551
|
inb = await self.inbounds_end.get_specific_inbound(inbound_id)
|
|
505
552
|
for client in inb.settings.clients:
|
|
@@ -507,19 +554,97 @@ class XUIClient:
|
|
|
507
554
|
return client
|
|
508
555
|
return None
|
|
509
556
|
|
|
510
|
-
async def
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
557
|
+
async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
|
|
558
|
+
security: str | None = None,
|
|
559
|
+
password: str | None = None,
|
|
560
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
561
|
+
limit_ip: int | None = None,
|
|
562
|
+
limit_gb: int | None = None,
|
|
563
|
+
expiry_time: int | None = None,
|
|
564
|
+
enable: bool | None = None,
|
|
565
|
+
sub_id: str | None = None,
|
|
566
|
+
comment: str | None = None,
|
|
567
|
+
verbose: bool = True
|
|
568
|
+
) -> list[Response]:
|
|
569
|
+
"""Update every matching client found by Telegram ID.
|
|
570
|
+
|
|
571
|
+
The client UUID is derived from ``telegram_id`` and searched across
|
|
572
|
+
either production inbounds or all inbounds. Only keyword arguments with
|
|
573
|
+
non-None values are applied to the client model before sending update
|
|
574
|
+
requests.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
telegram_id: Telegram ID used to derive the client UUID.
|
|
578
|
+
prod_only: If True, search only production inbounds. If False,
|
|
579
|
+
search every inbound returned by the panel.
|
|
580
|
+
security: New security setting.
|
|
581
|
+
password: New password.
|
|
582
|
+
flow: New VLESS flow value.
|
|
583
|
+
limit_ip: New simultaneous IP connection limit.
|
|
584
|
+
limit_gb: New traffic limit in gigabytes.
|
|
585
|
+
expiry_time: New expiry timestamp in seconds.
|
|
586
|
+
enable: New enabled state.
|
|
587
|
+
sub_id: New subscription ID.
|
|
588
|
+
comment: New client comment.
|
|
589
|
+
verbose: If True, warn when ``expiry_time`` looks like a duration
|
|
590
|
+
instead of a UNIX timestamp.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
Responses from each inbound where a matching client was updated.
|
|
594
|
+
"""
|
|
595
|
+
updates = {
|
|
596
|
+
"security": security,
|
|
597
|
+
"password": password,
|
|
598
|
+
"flow": flow,
|
|
599
|
+
"limit_ip": limit_ip,
|
|
600
|
+
"limit_gb": limit_gb,
|
|
601
|
+
"expiry_time": expiry_time,
|
|
602
|
+
"enable": enable,
|
|
603
|
+
"sub_id": sub_id,
|
|
604
|
+
"comment": comment,
|
|
605
|
+
}
|
|
606
|
+
# remove None values
|
|
607
|
+
updates = {k: v for k, v in updates.items() if v is not None}
|
|
608
|
+
|
|
609
|
+
if verbose:
|
|
610
|
+
if expiry_time and expiry_time < 1e9:
|
|
611
|
+
logging.warning("Warning: You're trying to update a client with expiry time %s. "
|
|
612
|
+
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
613
|
+
"You need to provide a TIMESTAMP. "
|
|
614
|
+
"If you want to disable this message, set verbose=false.",
|
|
615
|
+
expiry_time)
|
|
616
|
+
|
|
617
|
+
_to_exec: list[Task] = []
|
|
618
|
+
if prod_only:
|
|
619
|
+
self.get_production_inbounds.cache_clear()
|
|
620
|
+
inbounds = await self.get_production_inbounds()
|
|
621
|
+
else:
|
|
622
|
+
inbounds = await self.inbounds_end.get_all()
|
|
623
|
+
for inbound in inbounds:
|
|
624
|
+
found_client = util.get_inbound_in_client(util.get_uuid_from_tgid(telegram_id), inbound)
|
|
625
|
+
if found_client:
|
|
626
|
+
new_client = found_client.model_copy(update=updates, deep=True)
|
|
627
|
+
_to_exec.append(
|
|
628
|
+
asyncio.create_task(self.clients_end.request_update_client(
|
|
629
|
+
new_client, inbound.id, original_uuid=util.get_uuid_from_tgid(telegram_id)
|
|
630
|
+
))
|
|
631
|
+
)
|
|
632
|
+
responses = await asyncio.gather(*_to_exec)
|
|
633
|
+
return responses
|
|
634
|
+
|
|
635
|
+
async def update_client_by_tgid_inbid(self, telegram_id: int, inbound_id: int, /, *,
|
|
636
|
+
security: str | None = None,
|
|
637
|
+
password: str | None = None,
|
|
638
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
639
|
+
limit_ip: int | None = None,
|
|
640
|
+
limit_gb: int | None = None,
|
|
641
|
+
expiry_time: int | None = None,
|
|
642
|
+
enable: bool | None = None,
|
|
643
|
+
sub_id: str | None = None,
|
|
644
|
+
comment: str | None = None,
|
|
645
|
+
verbose: bool = True) -> Response:
|
|
521
646
|
"""
|
|
522
|
-
Update a client in a specific inbound by Telegram ID.
|
|
647
|
+
Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
|
|
523
648
|
|
|
524
649
|
Args:
|
|
525
650
|
telegram_id: The Telegram ID of the client
|
|
@@ -583,12 +708,12 @@ class XUIClient:
|
|
|
583
708
|
List of Response objects from each deletion attempt
|
|
584
709
|
"""
|
|
585
710
|
production_inbounds = await self.get_production_inbounds()
|
|
586
|
-
|
|
587
|
-
|
|
711
|
+
_to_exec: list[Task] = []
|
|
588
712
|
for inbound in production_inbounds:
|
|
589
713
|
email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
714
|
+
_to_exec.append(
|
|
715
|
+
asyncio.create_task(self.clients_end.delete_client_by_email(email, inbound.id))
|
|
716
|
+
)
|
|
717
|
+
logging.info("Clients of of tgid %s pending deletion", telegram_id)
|
|
718
|
+
responses = await asyncio.gather(*_to_exec)
|
|
594
719
|
return responses
|
|
@@ -7,7 +7,7 @@ import pydantic
|
|
|
7
7
|
from . import util
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
|
-
from api import XUIClient
|
|
10
|
+
from .api import XUIClient
|
|
11
11
|
|
|
12
12
|
class BaseModel(pydantic.BaseModel):
|
|
13
13
|
"""Base model for all 3X-UI API data models.
|
|
@@ -22,7 +22,7 @@ class BaseModel(pydantic.BaseModel):
|
|
|
22
22
|
ERROR_RETRIES: ClassVar[int] = 5
|
|
23
23
|
ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
|
|
24
24
|
|
|
25
|
-
model_config = pydantic.ConfigDict(ignored_types=(cached_property, ))
|
|
25
|
+
model_config = pydantic.ConfigDict(ignored_types=(cached_property, ), validate_by_name=True, validate_by_alias=True)
|
|
26
26
|
|
|
27
27
|
# def model_post_init(self, context: Any, /) -> None:
|
|
28
28
|
# #print(f"Model {self.__class__}, {self} initialized")
|
|
@@ -41,7 +41,7 @@ class BaseModel(pydantic.BaseModel):
|
|
|
41
41
|
A list of model instances initialized with the provided data.
|
|
42
42
|
|
|
43
43
|
Examples:
|
|
44
|
-
inbounds = Inbound.from_list([{"id": 1}, {"id": 2}]
|
|
44
|
+
inbounds = Inbound.from_list([{"id": 1}, {"id": 2}])
|
|
45
45
|
"""
|
|
46
46
|
return [cls(**obj) for obj in args]
|
|
47
47
|
|
|
@@ -86,6 +86,9 @@ class BaseModel(pydantic.BaseModel):
|
|
|
86
86
|
if expect is dict:
|
|
87
87
|
return cls(**obj)
|
|
88
88
|
else:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
if auto_retry:
|
|
90
|
+
req = response.request
|
|
91
|
+
new_resp = await client._safe_request(request_to_send=req)
|
|
92
|
+
return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
|
|
93
|
+
else:
|
|
94
|
+
raise util.DBLockedError("Failed to create model instance from response")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
class ClientEmailAlreadyExistsError(Exception):
|
|
3
|
+
"""Raised when the panel rejects a new client because its email exists."""
|
|
4
|
+
|
|
5
|
+
def __init__(self, *args):
|
|
6
|
+
if len(args) == 1:
|
|
7
|
+
super().__init__(args[0])
|
|
8
|
+
else:
|
|
9
|
+
super().__init__(*args)
|
|
10
|
+
|
|
11
|
+
class EmailNotExistsError(Exception):
|
|
12
|
+
"""Raised when a requested client email cannot be found on the panel."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args):
|
|
15
|
+
if len(args) == 1:
|
|
16
|
+
super().__init__(args[0])
|
|
17
|
+
else:
|
|
18
|
+
super().__init__(*args)
|
|
19
|
+
|
|
20
|
+
class ClientDoesNotExistError(Exception):
|
|
21
|
+
"""Raised when a requested client UUID is absent from the target inbound."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args):
|
|
24
|
+
if len(args) == 1:
|
|
25
|
+
super().__init__(args[0])
|
|
26
|
+
else:
|
|
27
|
+
super().__init__(*args)
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
from datetime import datetime, UTC
|
|
4
|
-
from typing import Generic, Literal, List, Dict
|
|
4
|
+
from typing import Generic, Literal, List, Dict, TypeVar, TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
from httpx import Response
|
|
7
|
-
from pydantic import ValidationError
|
|
8
|
-
from pydantic.main import ModelT
|
|
7
|
+
from pydantic import ValidationError, BaseModel
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
import pydantic
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .api import XUIClient
|
|
11
13
|
from .custom_exceptions import ClientDoesNotExistError
|
|
12
14
|
from .models import Inbound, SingleInboundClient, ClientStats, InboundClients, timestamp_seconds, ClientsSettings
|
|
13
15
|
from .util import JsonType
|
|
14
16
|
|
|
17
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class BaseEndpoint(Generic[ModelT]):
|
|
17
21
|
"""Base class for API endpoint handlers.
|
|
@@ -120,7 +124,7 @@ class Inbounds(BaseEndpoint):
|
|
|
120
124
|
"""
|
|
121
125
|
_url = "panel/api/inbounds"
|
|
122
126
|
|
|
123
|
-
async def get_all(self) ->
|
|
127
|
+
async def get_all(self) -> list[Inbound]:
|
|
124
128
|
"""Retrieve all inbounds from the server.
|
|
125
129
|
|
|
126
130
|
Returns:
|
|
@@ -131,7 +135,7 @@ class Inbounds(BaseEndpoint):
|
|
|
131
135
|
inbounds = Inbound.from_list(json_resp)
|
|
132
136
|
return inbounds
|
|
133
137
|
|
|
134
|
-
async def get_specific_inbound(self, inbound_id) -> Inbound:
|
|
138
|
+
async def get_specific_inbound(self, inbound_id: int) -> Inbound:
|
|
135
139
|
"""Retrieve a specific inbound by ID.
|
|
136
140
|
|
|
137
141
|
Args:
|
|
@@ -159,7 +163,7 @@ class Clients(BaseEndpoint):
|
|
|
159
163
|
- /panel/api/inbounds/delDepletedClients/{inbound_id}
|
|
160
164
|
- /panel/api/inbounds/{inbound_id}/delClient/{email|uuid}
|
|
161
165
|
"""
|
|
162
|
-
_url = "panel/api/inbounds
|
|
166
|
+
_url = "panel/api/inbounds"
|
|
163
167
|
|
|
164
168
|
#although it's the same url, they should be differentiated
|
|
165
169
|
|
|
@@ -172,11 +176,11 @@ class Clients(BaseEndpoint):
|
|
|
172
176
|
Returns:
|
|
173
177
|
A ClientStats model instance with the client's statistics.
|
|
174
178
|
"""
|
|
175
|
-
endpoint = f"getClientTraffics/{email}"
|
|
179
|
+
endpoint = f"/getClientTraffics/{email}"
|
|
176
180
|
resp = await self._simple_get(endpoint)
|
|
177
181
|
return ClientStats.model_validate(resp)
|
|
178
182
|
|
|
179
|
-
async def get_client_with_uuid(self, uuid: str) ->
|
|
183
|
+
async def get_client_with_uuid(self, uuid: str) -> list[ClientStats]:
|
|
180
184
|
"""Retrieve client statistics by UUID.
|
|
181
185
|
|
|
182
186
|
Args:
|
|
@@ -185,7 +189,7 @@ class Clients(BaseEndpoint):
|
|
|
185
189
|
Returns:
|
|
186
190
|
A list of ClientStats model instances matching the UUID.
|
|
187
191
|
"""
|
|
188
|
-
endpoint = f"getClientTrafficsById/{uuid}"
|
|
192
|
+
endpoint = f"/getClientTrafficsById/{uuid}"
|
|
189
193
|
resp = await self._simple_get(endpoint)
|
|
190
194
|
client_stats = ClientStats.from_list(resp)
|
|
191
195
|
return client_stats
|
|
@@ -209,8 +213,8 @@ class Clients(BaseEndpoint):
|
|
|
209
213
|
ValueError: If a single client is provided without an inbound_id.
|
|
210
214
|
TypeError: If the client type is not supported.
|
|
211
215
|
"""
|
|
212
|
-
endpoint = f"addClient"
|
|
213
|
-
if isinstance(client,
|
|
216
|
+
endpoint = f"/addClient"
|
|
217
|
+
if isinstance(client, dict):
|
|
214
218
|
try:
|
|
215
219
|
final = InboundClients.model_validate(client)
|
|
216
220
|
except ValidationError:
|
|
@@ -222,6 +226,8 @@ class Clients(BaseEndpoint):
|
|
|
222
226
|
else:
|
|
223
227
|
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
224
228
|
elif isinstance(client, SingleInboundClient):
|
|
229
|
+
if not inbound_id:
|
|
230
|
+
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
225
231
|
final = InboundClients(id=inbound_id,
|
|
226
232
|
settings=ClientsSettings(clients=[client]))
|
|
227
233
|
elif isinstance(client, InboundClients):
|
|
@@ -236,33 +242,31 @@ class Clients(BaseEndpoint):
|
|
|
236
242
|
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU IDIOT!
|
|
237
243
|
return resp
|
|
238
244
|
|
|
239
|
-
async def
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
async def request_update_client(self, client: InboundClients | SingleInboundClient,
|
|
246
|
+
inbound_id: int | None = None,
|
|
247
|
+
*, original_uuid: str | None = None) -> Response:
|
|
242
248
|
"""Request to update an existing client.
|
|
243
249
|
|
|
244
250
|
Args:
|
|
245
251
|
client: The client data to update. Can be:
|
|
246
|
-
- A ClientUpdatePayload - Recommended (requires inbound_id)
|
|
247
252
|
- A SingleInboundClient (requires inbound_id)
|
|
248
253
|
- An InboundClients object (with one client)
|
|
249
254
|
inbound_id: The ID of the inbound the client belongs to.
|
|
250
|
-
Required if client is a SingleInboundClient
|
|
255
|
+
Required if client is a SingleInboundClient.
|
|
251
256
|
original_uuid: The original UUID of the client to update.
|
|
252
|
-
Required
|
|
257
|
+
Required by the 3X-UI update endpoint.
|
|
253
258
|
|
|
254
259
|
Returns:
|
|
255
260
|
The HTTP response from the API.
|
|
256
261
|
"""
|
|
257
262
|
if isinstance(client, SingleInboundClient):
|
|
258
263
|
client = InboundClients(id=inbound_id, settings=ClientsSettings(clients=[client]))
|
|
259
|
-
_endpoint = f"updateClient/{original_uuid
|
|
260
|
-
#we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
|
|
264
|
+
_endpoint = f"/updateClient/{original_uuid}"
|
|
265
|
+
# we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
|
|
261
266
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}",
|
|
262
267
|
json=json.loads(client.model_dump_json(exclude_none=True, by_alias=True)))
|
|
263
268
|
return resp
|
|
264
269
|
|
|
265
|
-
|
|
266
270
|
async def update_single_client(self, inbound_id: int, client_uuid: str, *,
|
|
267
271
|
security: str | None = None,
|
|
268
272
|
password: str | None = None,
|
|
@@ -278,15 +282,15 @@ class Clients(BaseEndpoint):
|
|
|
278
282
|
"""Update an existing client's details.
|
|
279
283
|
|
|
280
284
|
Args:
|
|
281
|
-
client_uuid: The UUID of the original client.
|
|
282
285
|
inbound_id: The ID of the inbound the client belongs to.
|
|
286
|
+
client_uuid: The UUID of the original client.
|
|
283
287
|
security: New security settings (optional).
|
|
284
288
|
password: New password (optional).
|
|
285
289
|
flow: New flow settings (optional).
|
|
286
290
|
email: New email address (optional).
|
|
287
291
|
limit_ip: New IP limit (optional).
|
|
288
|
-
limit_gb: New
|
|
289
|
-
expiry_time: New expiry time (optional).
|
|
292
|
+
limit_gb: New traffic limit in gigabytes (optional).
|
|
293
|
+
expiry_time: New expiry time as a UNIX timestamp in seconds (optional).
|
|
290
294
|
enable: New enable status (optional).
|
|
291
295
|
sub_id: New subscription ID (optional).
|
|
292
296
|
comment: New comment (optional).
|
|
@@ -306,8 +310,9 @@ class Clients(BaseEndpoint):
|
|
|
306
310
|
raise ClientDoesNotExistError(f"The target inbound was checked but client {client_uuid} was not found.")
|
|
307
311
|
|
|
308
312
|
changes["updated_at"] = int(datetime.now(UTC).timestamp())
|
|
313
|
+
#TODO: see if model_copy actually does validation
|
|
309
314
|
updated = found_inbound.model_copy(update=changes)
|
|
310
|
-
resp = await self.
|
|
315
|
+
resp = await self.request_update_client(updated, inbound_id, original_uuid=client_uuid)
|
|
311
316
|
return resp
|
|
312
317
|
|
|
313
318
|
async def delete_expired_clients(self, inbound_id: int) -> Response:
|
|
@@ -319,7 +324,7 @@ class Clients(BaseEndpoint):
|
|
|
319
324
|
Returns:
|
|
320
325
|
The HTTP response from the API.
|
|
321
326
|
"""
|
|
322
|
-
_endpoint = f"delDepletedClients/"
|
|
327
|
+
_endpoint = f"/delDepletedClients/"
|
|
323
328
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}{inbound_id}")
|
|
324
329
|
return resp
|
|
325
330
|
|
|
@@ -333,7 +338,7 @@ class Clients(BaseEndpoint):
|
|
|
333
338
|
Returns:
|
|
334
339
|
The HTTP response from the API.
|
|
335
340
|
"""
|
|
336
|
-
_endpoint = f"{inbound_id}/delClientByEmail/{email}"
|
|
341
|
+
_endpoint = f"/{inbound_id}/delClientByEmail/{email}"
|
|
337
342
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
338
343
|
return resp
|
|
339
344
|
|
|
@@ -347,6 +352,6 @@ class Clients(BaseEndpoint):
|
|
|
347
352
|
Returns:
|
|
348
353
|
The HTTP response from the API.
|
|
349
354
|
"""
|
|
350
|
-
_endpoint = f"{inbound_id}/delClient/{uuid}"
|
|
355
|
+
_endpoint = f"/{inbound_id}/delClient/{uuid}"
|
|
351
356
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
352
357
|
return resp
|
|
@@ -2,7 +2,6 @@ import json
|
|
|
2
2
|
from datetime import datetime, UTC
|
|
3
3
|
from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassVar
|
|
4
4
|
|
|
5
|
-
import pydantic
|
|
6
5
|
from pydantic import field_validator, Field, field_serializer
|
|
7
6
|
from typing_extensions import TypeVar
|
|
8
7
|
|
|
@@ -31,7 +30,8 @@ def exclude_if_none(field) -> bool:
|
|
|
31
30
|
_IntNone = TypeVar("_IntNone", bound=int | None)
|
|
32
31
|
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
# noinspection PyNestedDecorators
|
|
34
|
+
class SingleInboundClient(base_model.BaseModel):
|
|
35
35
|
"""Represents a single client within a VLESS/VMess inbound.
|
|
36
36
|
|
|
37
37
|
This model represents an individual VPN client with all its configuration
|
|
@@ -58,11 +58,12 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
58
58
|
security: str = ""
|
|
59
59
|
password: str = ""
|
|
60
60
|
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
|
|
61
|
-
email:
|
|
61
|
+
email: str
|
|
62
62
|
limit_ip: Annotated[int, Field(alias="limitIp")] = 20
|
|
63
63
|
reset: int = 0
|
|
64
64
|
#Interestingly, the API expects this value to be called GB but it's actually bytes.
|
|
65
|
-
|
|
65
|
+
# I want the pythonic side to be in GB (hence why floats, i.e. 2.5GB), but the API expects bytes.
|
|
66
|
+
limit_gb: Annotated[int | float, Field(alias="totalGB")] = 0 # total flow
|
|
66
67
|
expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
|
|
67
68
|
enable: bool = True
|
|
68
69
|
tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
|
|
@@ -75,26 +76,30 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
75
76
|
default_factory=(lambda: int(datetime.now(UTC).timestamp())))
|
|
76
77
|
]
|
|
77
78
|
|
|
78
|
-
# noinspection PyNestedDecorators
|
|
79
79
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
80
80
|
@classmethod
|
|
81
81
|
def ensure_s_timestamp(cls, value: _IntNone) -> _IntNone:
|
|
82
82
|
return auto_ms_to_s_timestamp(value)
|
|
83
83
|
|
|
84
|
-
# noinspection PyNestedDecorators
|
|
85
84
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
86
85
|
@classmethod
|
|
87
|
-
def serialize_ms_timestamp(cls, value:
|
|
88
|
-
return auto_s_to_ms_timestamp(value)
|
|
86
|
+
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
87
|
+
return auto_s_to_ms_timestamp(value)
|
|
89
88
|
|
|
90
|
-
# noinspection PyNestedDecorators
|
|
91
89
|
@field_serializer("limit_gb")
|
|
92
90
|
@classmethod
|
|
93
|
-
def serialize_total_gb(cls, value:
|
|
94
|
-
|
|
91
|
+
def serialize_total_gb(cls, value: int | float) -> int:
|
|
92
|
+
#API expects an integer of bytes.
|
|
93
|
+
return value * (1024 ** 3)
|
|
95
94
|
|
|
95
|
+
@field_validator("limit_gb", mode="after")
|
|
96
|
+
@classmethod
|
|
97
|
+
def parse_total_gb(cls, value: int) -> int | float:
|
|
98
|
+
#Python wants an int/float of GB.
|
|
99
|
+
return value / (1024 ** 3)
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
|
|
102
|
+
class ClientsSettings(base_model.BaseModel):
|
|
98
103
|
"""Settings container for inbound clients.
|
|
99
104
|
|
|
100
105
|
Attributes:
|
|
@@ -103,10 +108,10 @@ class ClientsSettings(pydantic.BaseModel):
|
|
|
103
108
|
clients: list[SingleInboundClient]
|
|
104
109
|
decryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
105
110
|
encryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
106
|
-
fallbacks: Annotated[list|None, Field(exclude_if=exclude_if_none)] = None
|
|
111
|
+
fallbacks: Annotated[list | None, Field(exclude_if=exclude_if_none)] = None
|
|
107
112
|
|
|
108
113
|
|
|
109
|
-
class InboundClients(
|
|
114
|
+
class InboundClients(base_model.BaseModel):
|
|
110
115
|
"""Represents a collection of clients for an inbound connection.
|
|
111
116
|
|
|
112
117
|
This model is used when adding or updating clients on an inbound,
|
|
@@ -187,6 +192,7 @@ class InboundClients(pydantic.BaseModel):
|
|
|
187
192
|
# external_proxy: Annotated[list[ExternalProxy], Field(alias="externalProxy")]
|
|
188
193
|
# tcp_settings: TCPSettings
|
|
189
194
|
|
|
195
|
+
# noinspection PyNestedDecorators
|
|
190
196
|
class ClientStats(base_model.BaseModel):
|
|
191
197
|
"""Statistics and configuration for a VPN client.
|
|
192
198
|
|
|
@@ -203,10 +209,12 @@ class ClientStats(base_model.BaseModel):
|
|
|
203
209
|
up: Total uploaded bytes.
|
|
204
210
|
down: Total downloaded bytes.
|
|
205
211
|
allTime: Total bytes transferred (up + down).
|
|
206
|
-
expiryTime: Client expiry time as UNIX timestamp in
|
|
212
|
+
expiryTime: Client expiry time as a UNIX timestamp in seconds on the
|
|
213
|
+
Python model, serialized to milliseconds for the API.
|
|
207
214
|
total: Total data limit in bytes.
|
|
208
215
|
reset: Counter for traffic resets.
|
|
209
|
-
lastOnline: UNIX timestamp of last connection
|
|
216
|
+
lastOnline: UNIX timestamp of last connection in seconds on the
|
|
217
|
+
Python model, serialized to milliseconds for the API.
|
|
210
218
|
"""
|
|
211
219
|
TIME_FIELDS: ClassVar[List[str]] = ["expiryTime", "lastOnline"]
|
|
212
220
|
id: int
|
|
@@ -223,17 +231,18 @@ class ClientStats(base_model.BaseModel):
|
|
|
223
231
|
reset: int
|
|
224
232
|
lastOnline: timestamp_seconds
|
|
225
233
|
|
|
226
|
-
@classmethod
|
|
227
234
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
235
|
+
@classmethod
|
|
228
236
|
def ensure_s_timestamp(cls, value: int) -> int:
|
|
229
237
|
return auto_ms_to_s_timestamp(value)
|
|
230
238
|
|
|
231
|
-
@classmethod
|
|
232
239
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
240
|
+
@classmethod
|
|
233
241
|
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
234
242
|
return auto_s_to_ms_timestamp(value)
|
|
235
243
|
|
|
236
244
|
|
|
245
|
+
# noinspection PyNestedDecorators
|
|
237
246
|
class Inbound(base_model.BaseModel):
|
|
238
247
|
"""Represents a VPN inbound connection configuration.
|
|
239
248
|
|
|
@@ -274,7 +283,8 @@ class Inbound(base_model.BaseModel):
|
|
|
274
283
|
clientStats: list[ClientStats] | None
|
|
275
284
|
listen: str
|
|
276
285
|
port: int
|
|
277
|
-
|
|
286
|
+
#TODO: add trojan, shadowsocks, wireguard back in when they are supported by the API
|
|
287
|
+
protocol: Literal["vless", "vmess"] #"trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
|
|
278
288
|
settings: ClientsSettings # JSON packed value, stringified
|
|
279
289
|
streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
280
290
|
tag: str
|
|
@@ -315,12 +325,12 @@ class Inbound(base_model.BaseModel):
|
|
|
315
325
|
return ""
|
|
316
326
|
return json.dumps(value, ensure_ascii=False)
|
|
317
327
|
|
|
318
|
-
@classmethod
|
|
319
328
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
329
|
+
@classmethod
|
|
320
330
|
def ensure_s_timestamp(cls, value: int) -> int:
|
|
321
331
|
return auto_ms_to_s_timestamp(value)
|
|
322
332
|
|
|
323
|
-
@classmethod
|
|
324
333
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
334
|
+
@classmethod
|
|
325
335
|
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
326
336
|
return auto_s_to_ms_timestamp(value)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Utility functions and helpers for the
|
|
1
|
+
"""Utility functions and helpers for the python_3xui package.
|
|
2
2
|
|
|
3
|
-
This module provides common utilities used across the API
|
|
3
|
+
This module provides common utilities used across the 3X-UI API wrapper,
|
|
4
|
+
including:
|
|
4
5
|
- String conversion helpers (camelCase to snake_case)
|
|
5
6
|
- Async generators
|
|
6
7
|
- Base64 encoding utilities
|
|
@@ -14,10 +15,13 @@ import logging
|
|
|
14
15
|
import random
|
|
15
16
|
import re
|
|
16
17
|
from datetime import UTC, datetime, tzinfo
|
|
17
|
-
from typing import TypeAlias, Union, Dict, Any, List
|
|
18
|
+
from typing import TYPE_CHECKING, TypeAlias, Union, Dict, Any, List
|
|
18
19
|
|
|
19
20
|
import httpx
|
|
20
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from python_3xui.models import Inbound, SingleInboundClient
|
|
24
|
+
|
|
21
25
|
JsonType: TypeAlias = Union[Dict[Any, Any], List[Any]]
|
|
22
26
|
|
|
23
27
|
_RE_CAMEL_TO_SNAKE1 = re.compile("(.)([A-Z][a-z]+)")
|
|
@@ -57,13 +61,12 @@ async def async_range(start: int, stop: int|None=None, step: int=1):
|
|
|
57
61
|
Yields:
|
|
58
62
|
int: The next value in the range sequence.
|
|
59
63
|
"""
|
|
60
|
-
if stop:
|
|
64
|
+
if stop is not None:
|
|
61
65
|
range_ = range(start, stop, step)
|
|
62
66
|
else:
|
|
63
67
|
range_ = range(start)
|
|
64
68
|
for i in range_:
|
|
65
69
|
yield i
|
|
66
|
-
await asyncio.sleep(0)
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
|
|
@@ -71,10 +74,12 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
|
|
|
71
74
|
|
|
72
75
|
Args:
|
|
73
76
|
string: The input string to encode.
|
|
74
|
-
omit_trailing_equals:
|
|
77
|
+
omit_trailing_equals: Reserved for callers that do not want trailing
|
|
78
|
+
``=`` padding. The current implementation returns standard padded
|
|
79
|
+
base64 output.
|
|
75
80
|
|
|
76
81
|
Returns:
|
|
77
|
-
The base64
|
|
82
|
+
The base64-encoded string.
|
|
78
83
|
"""
|
|
79
84
|
return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
|
|
80
85
|
|
|
@@ -124,7 +129,15 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
|
|
|
124
129
|
return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
|
|
125
130
|
|
|
126
131
|
|
|
127
|
-
def random_string(length: int):
|
|
132
|
+
def random_string(length: int) -> str:
|
|
133
|
+
"""Generate a random alphanumeric string.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
length: Number of characters to generate.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A string made from ASCII letters and digits.
|
|
140
|
+
"""
|
|
128
141
|
s = "".join([random.choice(
|
|
129
142
|
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
|
|
130
143
|
])
|
|
@@ -145,6 +158,22 @@ def generate_random_email(length: int = 8) -> str:
|
|
|
145
158
|
return random_string(length)
|
|
146
159
|
|
|
147
160
|
|
|
161
|
+
def get_inbound_in_client(client_uuid: str, inbound: Inbound) -> SingleInboundClient|None:
|
|
162
|
+
"""Find a client inside an inbound by UUID.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
client_uuid: UUID of the client to find.
|
|
166
|
+
inbound: Inbound model whose client list should be searched.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The matching client, or None if the inbound does not contain it.
|
|
170
|
+
"""
|
|
171
|
+
for client in inbound.settings.clients:
|
|
172
|
+
if client.uuid == client_uuid:
|
|
173
|
+
return client
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
148
177
|
def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
|
|
149
178
|
"""Generate a deterministic email from Telegram ID and inbound ID.
|
|
150
179
|
|
|
@@ -165,7 +194,7 @@ def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
|
|
|
165
194
|
return f"TG{telegram_id}IB{inbound_id}"
|
|
166
195
|
|
|
167
196
|
|
|
168
|
-
def generate_new_subscription(length: int = 16):
|
|
197
|
+
def generate_new_subscription(length: int = 16) -> str:
|
|
169
198
|
"""Generate a random subscription ID.
|
|
170
199
|
|
|
171
200
|
Args:
|
|
@@ -180,7 +209,7 @@ def generate_new_subscription(length: int = 16):
|
|
|
180
209
|
return random_string(length)
|
|
181
210
|
|
|
182
211
|
|
|
183
|
-
async def check_xui_response(response:
|
|
212
|
+
async def check_xui_response(response: dict | httpx.Response) -> str:
|
|
184
213
|
"""Validate a 3X-UI API response.
|
|
185
214
|
|
|
186
215
|
Checks if the response follows the expected 3X-UI API format with
|
|
@@ -209,7 +238,7 @@ async def check_xui_response(response: JsonType | httpx.Response) -> str:
|
|
|
209
238
|
json_resp = response.json()
|
|
210
239
|
else:
|
|
211
240
|
json_resp = response
|
|
212
|
-
|
|
241
|
+
|
|
213
242
|
if len(json_resp) == 3:
|
|
214
243
|
if tuple(json_resp.keys()) == ("success", "msg", "obj"):
|
|
215
244
|
success: bool = json_resp["success"]
|
|
@@ -285,4 +314,4 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
|
|
|
285
314
|
|
|
286
315
|
def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
|
|
287
316
|
"""Get the current time as a UNIX timestamp in milliseconds."""
|
|
288
|
-
return int(datetime.now(tzinfo).timestamp()
|
|
317
|
+
return int(datetime.now(tzinfo).timestamp() * 1000)
|
{python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_non_idempotent_endpoints_clients.py
RENAMED
|
@@ -33,7 +33,7 @@ class TestClientsEndpoint:
|
|
|
33
33
|
# Try to find a suitable inbound (preferably with PROD_STRING in remark)
|
|
34
34
|
test_inbound = None
|
|
35
35
|
for inbound in all_inbounds:
|
|
36
|
-
if xui_client.PROD_STRING.search(inbound.remark
|
|
36
|
+
if xui_client.PROD_STRING.search(inbound.remark):
|
|
37
37
|
test_inbound = inbound
|
|
38
38
|
break
|
|
39
39
|
|
|
@@ -129,7 +129,7 @@ class TestXUIClientHelpers:
|
|
|
129
129
|
before = await xui_client.clients_end.get_client_with_email(email)
|
|
130
130
|
assert before.enable is True, "Newly created client should start enabled"
|
|
131
131
|
|
|
132
|
-
resp = await xui_client.
|
|
132
|
+
resp = await xui_client.update_client_by_tgid_inbid(
|
|
133
133
|
_TGID_UPDATE, target_inbound.id, verbose=False, sub_id=_TEST_SUB_ID,
|
|
134
134
|
)
|
|
135
135
|
assert resp.status_code == 200
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
class ClientEmailAlreadyExistsError(Exception):
|
|
3
|
-
def __init__(self, *args):
|
|
4
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
5
|
-
|
|
6
|
-
class EmailNotExistsError(Exception):
|
|
7
|
-
def __init__(self, *args):
|
|
8
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
9
|
-
|
|
10
|
-
class ClientDoesNotExistError(Exception):
|
|
11
|
-
def __init__(self, *args):
|
|
12
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_non_idempotent_endpoints_inbounds.py
RENAMED
|
File without changes
|