Python-3xui 0.0.10.post2__tar.gz → 0.0.11__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.10.post2 → python_3xui-0.0.11}/PKG-INFO +1 -1
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/pyproject.toml +3 -2
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/__init__.py +1 -1
- python_3xui-0.0.11/python_3xui/api_core/__init__.py +4 -0
- python_3xui-0.0.11/python_3xui/api_core/client_service.py +301 -0
- python_3xui-0.0.11/python_3xui/api_core/identity.py +28 -0
- python_3xui-0.0.11/python_3xui/api_core/prod_cache.py +75 -0
- python_3xui-0.0.11/python_3xui/api_core/session_core.py +333 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/.gitignore +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/LICENSE +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/README.md +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/api.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/base_model.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/custom_exceptions.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/endpoints.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/models.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/util.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/conftest.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/pytest.ini +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_clients.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_xuiclient_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "Python-3xui"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.11"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
6
|
]
|
|
@@ -46,7 +46,8 @@ build-backend = "hatchling.build"
|
|
|
46
46
|
include = [
|
|
47
47
|
"python_3xui/*.py",
|
|
48
48
|
"/tests/*.py",
|
|
49
|
-
"/tests/pytest.ini"
|
|
49
|
+
"/tests/pytest.ini",
|
|
50
|
+
"python_3xui/api_core/*.py"
|
|
50
51
|
]
|
|
51
52
|
exclude = [
|
|
52
53
|
"requirements.txt",
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Telegram-ID-oriented high-level client operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from asyncio import Task
|
|
8
|
+
from datetime import datetime, UTC
|
|
9
|
+
from typing import TYPE_CHECKING, List, Literal
|
|
10
|
+
|
|
11
|
+
from httpx import Response
|
|
12
|
+
|
|
13
|
+
from python_3xui import util
|
|
14
|
+
from python_3xui.custom_exceptions import ClientDoesNotExistError, ClientEmailAlreadyExistsError
|
|
15
|
+
from python_3xui.models import ClientStats, Inbound, SingleInboundClient
|
|
16
|
+
from python_3xui.util import get_client_in_inbound
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from python_3xui.endpoints import Clients, Inbounds
|
|
20
|
+
from python_3xui.api_core.identity import IdentityResolver
|
|
21
|
+
from python_3xui.api_core.prod_cache import ProductionInboundCache
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TgIDClientService:
|
|
25
|
+
"""Orchestrates TGID-derived flows against panel endpoints."""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache")
|
|
28
|
+
|
|
29
|
+
def __init__(self,
|
|
30
|
+
clients_endpoint: Clients,
|
|
31
|
+
inbounds_endpoint: Inbounds,
|
|
32
|
+
identity: IdentityResolver,
|
|
33
|
+
prod_cache: ProductionInboundCache,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._clients = clients_endpoint
|
|
36
|
+
self._inbounds = inbounds_endpoint
|
|
37
|
+
self._identity = identity
|
|
38
|
+
self._prod_cache = prod_cache
|
|
39
|
+
|
|
40
|
+
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
|
|
41
|
+
#FIXME: Implement the fallback described in the docstring (api.py)
|
|
42
|
+
if inbound_id:
|
|
43
|
+
email = util.generate_email_from_tgid_inbid(tgid, inbound_id)
|
|
44
|
+
return [await self._clients.get_client_with_email(email)]
|
|
45
|
+
uuid = await self._identity.resolve_uuid(tgid)
|
|
46
|
+
return await self._clients.get_client_with_uuid(uuid)
|
|
47
|
+
|
|
48
|
+
async def create_and_add_prod_client(self,
|
|
49
|
+
telegram_id: int,
|
|
50
|
+
*,
|
|
51
|
+
additional_remark: str | None = None,
|
|
52
|
+
expiry_time: int = 0,
|
|
53
|
+
exist_ok: bool = False,
|
|
54
|
+
replace_if_exist: bool = False,
|
|
55
|
+
) -> dict[int, Response]:
|
|
56
|
+
production_inbounds: tuple[Inbound, ...] = await self._prod_cache.get()
|
|
57
|
+
|
|
58
|
+
custom_sub = await self._identity.resolve_sub(telegram_id)
|
|
59
|
+
uuid = await self._identity.resolve_uuid(telegram_id)
|
|
60
|
+
|
|
61
|
+
# --- Phase 1: build clients and burst-add them ---
|
|
62
|
+
_to_exec: list[asyncio.Task[Response]] = []
|
|
63
|
+
clients_by_inbound: dict[int, SingleInboundClient] = {}
|
|
64
|
+
for inb in production_inbounds:
|
|
65
|
+
tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
|
|
66
|
+
client = SingleInboundClient(
|
|
67
|
+
uuid=uuid,
|
|
68
|
+
flow="",
|
|
69
|
+
email=tmp_email,
|
|
70
|
+
limit_gb=0,
|
|
71
|
+
enable=True,
|
|
72
|
+
subscription_id=custom_sub,
|
|
73
|
+
comment=f"{additional_remark + ', ' if additional_remark else ''}created at {datetime.now(UTC)}",
|
|
74
|
+
expiry_time=expiry_time,
|
|
75
|
+
)
|
|
76
|
+
clients_by_inbound[inb.id] = client
|
|
77
|
+
_to_exec.append(
|
|
78
|
+
asyncio.create_task(self._clients.add_client(client, inb.id))
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
raw_results: list[Response] = await asyncio.gather(*_to_exec)
|
|
82
|
+
|
|
83
|
+
# Map inbound IDs to their add responses
|
|
84
|
+
responses: dict[int, Response] = {}
|
|
85
|
+
for i, inb in enumerate(production_inbounds):
|
|
86
|
+
responses[inb.id] = raw_results[i]
|
|
87
|
+
|
|
88
|
+
# --- Phase 2: replace duplicates when replace_if_exist is set ---
|
|
89
|
+
if replace_if_exist:
|
|
90
|
+
_update_exec: list[asyncio.Task[Response]] = []
|
|
91
|
+
# Track which inbound each update task corresponds to via list index
|
|
92
|
+
update_inbound_ids: list[int] = []
|
|
93
|
+
|
|
94
|
+
for inb_id, resp in list(responses.items()):
|
|
95
|
+
json_resp = resp.json()
|
|
96
|
+
msg = json_resp.get("msg", "")
|
|
97
|
+
if "duplicate email" in msg.lower():
|
|
98
|
+
client = clients_by_inbound[inb_id]
|
|
99
|
+
_update_exec.append(
|
|
100
|
+
asyncio.create_task(
|
|
101
|
+
self._clients.request_update_client(
|
|
102
|
+
client, inb_id, original_uuid=client.uuid,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
update_inbound_ids.append(inb_id)
|
|
107
|
+
|
|
108
|
+
if _update_exec:
|
|
109
|
+
update_results: list[Response] = await asyncio.gather(*_update_exec)
|
|
110
|
+
for i, inb_id in enumerate(update_inbound_ids):
|
|
111
|
+
responses[inb_id] = update_results[i]
|
|
112
|
+
|
|
113
|
+
# --- Phase 3: raise on remaining duplicates if not exist_ok ---
|
|
114
|
+
if not exist_ok:
|
|
115
|
+
for inb_id, resp in responses.items():
|
|
116
|
+
json_resp = resp.json()
|
|
117
|
+
msg = json_resp.get("msg", "")
|
|
118
|
+
if "duplicate email" in msg.lower():
|
|
119
|
+
logging.error(
|
|
120
|
+
"ERROR: Client already exists and exist_ok not set: %s",
|
|
121
|
+
msg,
|
|
122
|
+
)
|
|
123
|
+
raise ClientEmailAlreadyExistsError(msg)
|
|
124
|
+
|
|
125
|
+
return responses
|
|
126
|
+
|
|
127
|
+
async def _find_client_in_inbound(self,
|
|
128
|
+
client_uuid: str,
|
|
129
|
+
inbound_id: int,
|
|
130
|
+
*,
|
|
131
|
+
use_cache: bool = False,
|
|
132
|
+
) -> SingleInboundClient | None:
|
|
133
|
+
if use_cache:
|
|
134
|
+
prod_inbs = await self._prod_cache.get()
|
|
135
|
+
prod_inb_index = None
|
|
136
|
+
for i, prod_inb in enumerate(prod_inbs):
|
|
137
|
+
if inbound_id == prod_inb.id:
|
|
138
|
+
prod_inb_index = i
|
|
139
|
+
|
|
140
|
+
if prod_inb_index is not None:
|
|
141
|
+
needed_inb: Inbound = prod_inbs[prod_inb_index]
|
|
142
|
+
result = get_client_in_inbound(client_uuid, needed_inb)
|
|
143
|
+
if result is None:
|
|
144
|
+
self._prod_cache.get.cache_clear()
|
|
145
|
+
new_inb = (await self._prod_cache.get())[prod_inb_index]
|
|
146
|
+
return get_client_in_inbound(client_uuid, new_inb)
|
|
147
|
+
|
|
148
|
+
inb = await self._inbounds.get_specific_inbound(inbound_id)
|
|
149
|
+
for client in inb.settings.clients:
|
|
150
|
+
if client.uuid == client_uuid:
|
|
151
|
+
return client
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
async def update_client_by_tgid_only(self,
|
|
155
|
+
telegram_id: int,
|
|
156
|
+
prod_only: bool,
|
|
157
|
+
/,
|
|
158
|
+
*,
|
|
159
|
+
security: str | None = None,
|
|
160
|
+
password: str | None = None,
|
|
161
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
162
|
+
limit_ip: int | None = None,
|
|
163
|
+
limit_gb: int | None = None,
|
|
164
|
+
expiry_time: int | None = None,
|
|
165
|
+
enable: bool | None = None,
|
|
166
|
+
sub_id: str | None = None,
|
|
167
|
+
comment: str | None = None,
|
|
168
|
+
verbose: bool = True,
|
|
169
|
+
force_search_by_email: bool = False,
|
|
170
|
+
not_found_action: Literal["raise", "ignore"] = "ignore",
|
|
171
|
+
client_to_create: SingleInboundClient | None = None
|
|
172
|
+
) -> list[Response]:
|
|
173
|
+
updates = {
|
|
174
|
+
"security": security,
|
|
175
|
+
"password": password,
|
|
176
|
+
"flow": flow,
|
|
177
|
+
"limit_ip": limit_ip,
|
|
178
|
+
"limit_gb": limit_gb,
|
|
179
|
+
"expiry_time": expiry_time,
|
|
180
|
+
"enable": enable,
|
|
181
|
+
"sub_id": sub_id,
|
|
182
|
+
"comment": comment,
|
|
183
|
+
}
|
|
184
|
+
updates = {k: v for k, v in updates.items() if v is not None}
|
|
185
|
+
|
|
186
|
+
if verbose:
|
|
187
|
+
if expiry_time and expiry_time < 1e9:
|
|
188
|
+
logging.warning(
|
|
189
|
+
"Warning: You're trying to update a client with expiry time %s. "
|
|
190
|
+
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
191
|
+
"You need to provide a TIMESTAMP. "
|
|
192
|
+
"If you want to disable this message, set verbose=false.",
|
|
193
|
+
expiry_time,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
_to_exec: list[Task] = []
|
|
197
|
+
client_uuid = await self._identity.resolve_uuid(telegram_id)
|
|
198
|
+
if prod_only:
|
|
199
|
+
self._prod_cache.get.cache_clear()
|
|
200
|
+
inbounds = await self._prod_cache.get()
|
|
201
|
+
else:
|
|
202
|
+
inbounds = await self._inbounds.get_all()
|
|
203
|
+
for inbound in inbounds:
|
|
204
|
+
found_client = util.get_client_in_inbound(client_uuid, inbound)
|
|
205
|
+
if not found_client:
|
|
206
|
+
if force_search_by_email:
|
|
207
|
+
_email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
208
|
+
found_traffics = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
|
|
209
|
+
if found_traffics:
|
|
210
|
+
resp = await self._inbounds.get_specific_inbound(inbound.id)
|
|
211
|
+
found_client = util.get_client_in_inbound(found_traffics.uuid, resp)
|
|
212
|
+
# this double-check is better than 2 branches doing the same thing
|
|
213
|
+
if not found_client:
|
|
214
|
+
if not_found_action == "ignore":
|
|
215
|
+
pass
|
|
216
|
+
if not_found_action == "raise":
|
|
217
|
+
raise ClientDoesNotExistError(f"Client not found: {client_uuid}")
|
|
218
|
+
|
|
219
|
+
if found_client:
|
|
220
|
+
new_client = found_client.model_copy(update=updates, deep=True)
|
|
221
|
+
_to_exec.append(
|
|
222
|
+
asyncio.create_task(
|
|
223
|
+
self._clients.request_update_client(
|
|
224
|
+
new_client, inbound.id, original_uuid=client_uuid
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return await asyncio.gather(*_to_exec)
|
|
229
|
+
|
|
230
|
+
async def update_client_by_tgid_inbid(self,
|
|
231
|
+
telegram_id: int,
|
|
232
|
+
inbound_id: int,
|
|
233
|
+
/,
|
|
234
|
+
*,
|
|
235
|
+
security: str | None = None,
|
|
236
|
+
password: str | None = None,
|
|
237
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
238
|
+
limit_ip: int | None = None,
|
|
239
|
+
limit_gb: int | None = None,
|
|
240
|
+
expiry_time: int | None = None,
|
|
241
|
+
enable: bool | None = None,
|
|
242
|
+
sub_id: str | None = None,
|
|
243
|
+
comment: str | None = None,
|
|
244
|
+
email: str | None = None,
|
|
245
|
+
verbose: bool = True,
|
|
246
|
+
force_resolve_by_email: bool = False,
|
|
247
|
+
) -> Response:
|
|
248
|
+
if verbose:
|
|
249
|
+
if expiry_time and expiry_time < 1e9:
|
|
250
|
+
logging.warning(
|
|
251
|
+
"Warning: You're trying to update a client with expiry time %s. "
|
|
252
|
+
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
253
|
+
"You need to provide a TIMESTAMP. "
|
|
254
|
+
"If you want to disable this message, set verbose=false.",
|
|
255
|
+
expiry_time,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
client_uuid = await self._identity.resolve_uuid(telegram_id)
|
|
259
|
+
found = await self._find_client_in_inbound(client_uuid, inbound_id)
|
|
260
|
+
if not found:
|
|
261
|
+
if force_resolve_by_email:
|
|
262
|
+
_email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
263
|
+
resp = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
|
|
264
|
+
if resp is None:
|
|
265
|
+
raise ClientDoesNotExistError(f"The target inbound was force-checked by email but client {_email_to_search} was not found.")
|
|
266
|
+
client_uuid = resp.uuid
|
|
267
|
+
else:
|
|
268
|
+
raise ClientDoesNotExistError(
|
|
269
|
+
f"The target inbound was checked but client {client_uuid} was not found."
|
|
270
|
+
)
|
|
271
|
+
return await self._clients.update_single_client(
|
|
272
|
+
inbound_id=inbound_id,
|
|
273
|
+
found_client=found,
|
|
274
|
+
security=security,
|
|
275
|
+
password=password,
|
|
276
|
+
email=email,
|
|
277
|
+
flow=flow,
|
|
278
|
+
limit_ip=limit_ip,
|
|
279
|
+
limit_gb=limit_gb,
|
|
280
|
+
expiry_time=expiry_time,
|
|
281
|
+
enable=enable,
|
|
282
|
+
sub_id=sub_id,
|
|
283
|
+
comment=comment,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int) -> Response:
|
|
287
|
+
email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
288
|
+
return await self._clients.delete_client_by_email(email, inbound_id)
|
|
289
|
+
|
|
290
|
+
async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
|
|
291
|
+
production_inbounds = await self._prod_cache.get()
|
|
292
|
+
_to_exec: list[Task] = []
|
|
293
|
+
for inbound in production_inbounds:
|
|
294
|
+
email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
295
|
+
_to_exec.append(
|
|
296
|
+
asyncio.create_task(
|
|
297
|
+
self._clients.delete_client_by_email(email, inbound.id)
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
logging.info("Clients of of tgid %s pending deletion", telegram_id)
|
|
301
|
+
return await asyncio.gather(*_to_exec)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from inspect import iscoroutinefunction
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IdentityResolver:
|
|
6
|
+
"""Resolves Telegram IDs to UUIDs and subscription IDs.
|
|
7
|
+
|
|
8
|
+
Wraps user-supplied sync-or-async generator callables and exposes
|
|
9
|
+
consistently-async resolve_* methods. Pure (no I/O, no state beyond the
|
|
10
|
+
injected callables).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self,
|
|
14
|
+
sub_gen: Callable[[int], str] | Callable[[int], Awaitable[str]],
|
|
15
|
+
uuid_gen: Callable[[int], str] | Callable[[int], Awaitable[str]],
|
|
16
|
+
) -> None:
|
|
17
|
+
self.sub_gen = sub_gen
|
|
18
|
+
self.uuid_gen = uuid_gen
|
|
19
|
+
|
|
20
|
+
async def resolve_uuid(self, telegram_id: int) -> str:
|
|
21
|
+
if iscoroutinefunction(self.uuid_gen):
|
|
22
|
+
return await self.uuid_gen(telegram_id)
|
|
23
|
+
return self.uuid_gen(telegram_id)
|
|
24
|
+
|
|
25
|
+
async def resolve_sub(self, telegram_id: int) -> str:
|
|
26
|
+
if iscoroutinefunction(self.sub_gen):
|
|
27
|
+
return await self.sub_gen(telegram_id)
|
|
28
|
+
return self.sub_gen(telegram_id)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from asyncio import Task
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from async_lru import alru_cache
|
|
11
|
+
|
|
12
|
+
from python_3xui.models import Inbound
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from python_3xui.endpoints import Inbounds
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProductionInboundCache:
|
|
19
|
+
"""Per-instance cache of production inbounds + background refresher.
|
|
20
|
+
|
|
21
|
+
The wrapper around the fetch impl is built per-instance (not via a
|
|
22
|
+
class-level decorator) so each XUIClient owns its own async-lru cache
|
|
23
|
+
bound to its own event loop. Using a class-level ``@alru_cache()`` on
|
|
24
|
+
the underlying coroutine binds the cache to the first event loop that
|
|
25
|
+
touches it (see async_lru._check_loop), which breaks any caller that
|
|
26
|
+
creates a new XUIClient on a fresh loop (e.g. each pytest-asyncio test).
|
|
27
|
+
Building the wrapper in ``__init__`` gives every instance its own cache
|
|
28
|
+
bound to its own loop.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, inbounds_endpoint: Inbounds, prod_string_pattern: str,
|
|
32
|
+
*, panel_id: Any = None, refresh_interval: float = 3600, cache_size: int = 128,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._inbounds = inbounds_endpoint
|
|
35
|
+
self.PROD_STRING = re.compile(prod_string_pattern)
|
|
36
|
+
self.panel_id = panel_id
|
|
37
|
+
self._refresh_interval = refresh_interval
|
|
38
|
+
self.get = alru_cache(maxsize=cache_size)(self._fetch_impl)
|
|
39
|
+
self._task: Task | None = None
|
|
40
|
+
self._running: bool = False
|
|
41
|
+
|
|
42
|
+
async def _fetch_impl(self) -> tuple[Inbound, ...]:
|
|
43
|
+
inbounds = await self._inbounds.get_all()
|
|
44
|
+
usable: list[Inbound] = [inb for inb in inbounds if self.PROD_STRING.search(inb.remark)]
|
|
45
|
+
if not usable:
|
|
46
|
+
# Note: this is meant to fail if the prod_string is not found.
|
|
47
|
+
raise RuntimeError("No production inbounds found! Change prod_string!")
|
|
48
|
+
return tuple(usable)
|
|
49
|
+
|
|
50
|
+
def start(self, *, create_new: bool = False) -> None:
|
|
51
|
+
"""Idempotent. Mirrors the create_new guard from the original task."""
|
|
52
|
+
if self._task is not None and not create_new:
|
|
53
|
+
logging.warning(
|
|
54
|
+
"Cache cleaner task already running; pass create_new=True to override."
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
self._running = True
|
|
58
|
+
logging.info("Initializing cache cleaner task for %s", self.panel_id)
|
|
59
|
+
self._task = asyncio.create_task(
|
|
60
|
+
self._refresh_loop(),
|
|
61
|
+
name=f"inb_cache_clearer_for_{self.panel_id}",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def stop(self) -> None:
|
|
65
|
+
self._running = False
|
|
66
|
+
if self._task is not None:
|
|
67
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
68
|
+
self._task.cancel("Panel is exiting.")
|
|
69
|
+
self._task = None
|
|
70
|
+
|
|
71
|
+
async def _refresh_loop(self) -> None:
|
|
72
|
+
while self._running:
|
|
73
|
+
self.get.cache_clear()
|
|
74
|
+
await self.get()
|
|
75
|
+
await asyncio.sleep(self._refresh_interval)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""HTTP session, credentials, TOTP, and retry-with-relogin policy for the panel API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterable, Iterable, Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from logging import DEBUG
|
|
11
|
+
from typing import Any, Literal, Self, Tuple, Type, Union, overload
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pyotp
|
|
15
|
+
from httpx import AsyncClient, Request, Response
|
|
16
|
+
from pydantic import SecretStr
|
|
17
|
+
|
|
18
|
+
from python_3xui import util
|
|
19
|
+
from python_3xui.util import JsonType, async_range
|
|
20
|
+
|
|
21
|
+
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[
|
|
22
|
+
str, bytes, Iterable[bytes], AsyncIterable[bytes]
|
|
23
|
+
]
|
|
24
|
+
PrimitiveData = Union[str, int, float, bool] | None
|
|
25
|
+
ParamType = Union[
|
|
26
|
+
Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
|
|
27
|
+
list[Tuple[str, PrimitiveData]],
|
|
28
|
+
tuple[Tuple[str, PrimitiveData], ...],
|
|
29
|
+
str,
|
|
30
|
+
bytes,
|
|
31
|
+
]
|
|
32
|
+
CookieType = Union[dict[str, str], list[tuple[str, str]]]
|
|
33
|
+
HeaderType = Union[
|
|
34
|
+
Mapping[str, str],
|
|
35
|
+
Mapping[bytes, bytes],
|
|
36
|
+
Sequence[tuple[str, str]],
|
|
37
|
+
Sequence[tuple[bytes, bytes]],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionCore:
|
|
42
|
+
"""Owns HTTP session, credentials, TOTP, and the retry-with-relogin policy.
|
|
43
|
+
|
|
44
|
+
Single object that talks to the panel. Endpoint classes receive a
|
|
45
|
+
SessionCore (not the full XUIClient) and call ``safe_get`` / ``safe_post``
|
|
46
|
+
on it. The 404-with-expired-session branch in ``_safe_request`` calls
|
|
47
|
+
``self.login()``, so transport and auth must live together.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
51
|
+
*, username: str | None = None, password: str | None = None,
|
|
52
|
+
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
53
|
+
max_retries: int = 5, retry_delay: int = 1, panel_id: Any = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.connected: bool = False
|
|
56
|
+
self.session: AsyncClient | None = None
|
|
57
|
+
self.base_host: str = base_website
|
|
58
|
+
self.base_port: int = base_port
|
|
59
|
+
self.base_path: str = base_path
|
|
60
|
+
self.base_url: str = f"https://{self.base_host}:{self.base_port}{self.base_path}"
|
|
61
|
+
self.session_start: float | None = None
|
|
62
|
+
self.session_duration: int = session_duration
|
|
63
|
+
self.xui_username: str | None = username
|
|
64
|
+
self.xui_password: str | None = password
|
|
65
|
+
self.two_fac_secret: SecretStr | None = (
|
|
66
|
+
SecretStr(two_fac_code) if two_fac_code is not None else None
|
|
67
|
+
)
|
|
68
|
+
self.totp: pyotp.TOTP | None = None
|
|
69
|
+
self.max_retries: int = max_retries
|
|
70
|
+
self.retry_delay: int = retry_delay
|
|
71
|
+
self.panel_id = panel_id
|
|
72
|
+
if self.two_fac_secret:
|
|
73
|
+
if len(self.two_fac_secret.get_secret_value()) <= 8:
|
|
74
|
+
print(
|
|
75
|
+
"WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
|
|
76
|
+
"Although entering the secret is dangerous, there is no other way to provide a consistent way"
|
|
77
|
+
"for continuous login. This code will only work for this specific login."
|
|
78
|
+
)
|
|
79
|
+
self.totp = None
|
|
80
|
+
else:
|
|
81
|
+
self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
|
|
82
|
+
|
|
83
|
+
@overload
|
|
84
|
+
async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@overload
|
|
88
|
+
async def _safe_request(self,
|
|
89
|
+
method: Literal["get", "post", "patch", "delete", "put"],
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> Response:
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
async def _safe_request(self,
|
|
95
|
+
method: Literal["get", "post", "patch", "delete", "put"] | None = None,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> Response:
|
|
98
|
+
"""Execute an HTTP request with automatic retry on database lock.
|
|
99
|
+
|
|
100
|
+
The request can be made either from a prebuilt ``request_to_send`` or
|
|
101
|
+
from an HTTP method plus keyword arguments accepted by ``httpx``.
|
|
102
|
+
The method handles automatic session refresh on expired 404 responses
|
|
103
|
+
and retries when the 3X-UI database is locked.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
method: The HTTP method to use when building a new request.
|
|
107
|
+
**kwargs: Either ``request_to_send`` by itself, or request
|
|
108
|
+
arguments such as ``url``, ``json``, ``params``, and headers.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The HTTP response.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If neither a method nor a prebuilt request is provided,
|
|
115
|
+
or both request styles are mixed.
|
|
116
|
+
RuntimeError: If max retries are exceeded or a valid session gets
|
|
117
|
+
an unexpected 404 response.
|
|
118
|
+
"""
|
|
119
|
+
if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"Provide either a prebuilt request or arguments to build one."
|
|
122
|
+
)
|
|
123
|
+
if "request_to_send" not in kwargs:
|
|
124
|
+
if method is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"If there's no prebuilt request, you must provide a method."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
url = (
|
|
130
|
+
kwargs["url"]
|
|
131
|
+
if "url" in kwargs.keys()
|
|
132
|
+
else kwargs["request_to_send"].url
|
|
133
|
+
)
|
|
134
|
+
if "json" in kwargs:
|
|
135
|
+
json_payload = kwargs["json"]
|
|
136
|
+
elif "request_to_send" in kwargs:
|
|
137
|
+
_req = kwargs["request_to_send"]
|
|
138
|
+
if _req.content:
|
|
139
|
+
try:
|
|
140
|
+
json_payload = json.loads(_req.content.decode())
|
|
141
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
142
|
+
json_payload = None
|
|
143
|
+
else:
|
|
144
|
+
json_payload = None
|
|
145
|
+
else:
|
|
146
|
+
json_payload = None
|
|
147
|
+
if __debug__:
|
|
148
|
+
logging.info(
|
|
149
|
+
"Safe %s is running to %s%s\nJSON Payload: %s",
|
|
150
|
+
method,
|
|
151
|
+
str(self.session.base_url),
|
|
152
|
+
str(url),
|
|
153
|
+
json.dumps(json_payload) if json_payload is not None else "(no payload)",
|
|
154
|
+
)
|
|
155
|
+
async for attempt in async_range(self.max_retries):
|
|
156
|
+
if "request_to_send" in kwargs:
|
|
157
|
+
_request: Request = kwargs["request_to_send"]
|
|
158
|
+
resp = await self.session.send(_request)
|
|
159
|
+
else:
|
|
160
|
+
# noinspection PyTypeChecker
|
|
161
|
+
resp = await self.session.request(method, **kwargs)
|
|
162
|
+
if resp.status_code // 100 != 2: # because it can return either 201 or 202
|
|
163
|
+
if resp.status_code == 404:
|
|
164
|
+
now: float = datetime.now(UTC).timestamp()
|
|
165
|
+
if (
|
|
166
|
+
self.session_start is None
|
|
167
|
+
or now - self.session_start > self.session_duration
|
|
168
|
+
):
|
|
169
|
+
logging.info(
|
|
170
|
+
"Client (panel: %s) is not logged in, logging in...",
|
|
171
|
+
self.panel_id or self.base_host,
|
|
172
|
+
)
|
|
173
|
+
await self.login()
|
|
174
|
+
continue
|
|
175
|
+
else:
|
|
176
|
+
logging.error(
|
|
177
|
+
"Server returned a status code of %s with a valid session",
|
|
178
|
+
resp.status_code,
|
|
179
|
+
)
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
"Server returned a 404, and the session should still be valid, likely it's a REAL 404"
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
logging.error(
|
|
185
|
+
"Server returned a status code of %s", resp.status_code
|
|
186
|
+
)
|
|
187
|
+
resp.raise_for_status()
|
|
188
|
+
|
|
189
|
+
status = await util.check_xui_response(resp)
|
|
190
|
+
if status == "OK":
|
|
191
|
+
return resp
|
|
192
|
+
if status == "DB_LOCKED":
|
|
193
|
+
if attempt + 1 >= self.max_retries:
|
|
194
|
+
raise RuntimeError("Too many retries")
|
|
195
|
+
await asyncio.sleep(self.retry_delay)
|
|
196
|
+
continue
|
|
197
|
+
logging.error(
|
|
198
|
+
"A %s request was unsuccessful (code 200, but success=false).\nPayload: %s",
|
|
199
|
+
method,
|
|
200
|
+
json.dumps(resp.json()),
|
|
201
|
+
)
|
|
202
|
+
return resp
|
|
203
|
+
raise RuntimeError(
|
|
204
|
+
f"For some reason safe_request didn't exit, dump:\nmethod:\n{method}\n{kwargs}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def safe_get(self,
|
|
208
|
+
url: httpx.URL | str,
|
|
209
|
+
*,
|
|
210
|
+
params: ParamType | None = None,
|
|
211
|
+
headers: HeaderType | None = None,
|
|
212
|
+
cookies: CookieType | None = None,
|
|
213
|
+
) -> Response:
|
|
214
|
+
"""Execute a safe GET request with automatic retry on database lock.
|
|
215
|
+
|
|
216
|
+
Note:
|
|
217
|
+
"Safe" only means "with retries if database is locked".
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
url: The URL to request.
|
|
221
|
+
params: Query parameters (optional).
|
|
222
|
+
headers: Request headers (optional).
|
|
223
|
+
cookies: Request cookies (optional).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The HTTP response.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
RuntimeError: If the session is not initialized.
|
|
230
|
+
"""
|
|
231
|
+
if self.session is None:
|
|
232
|
+
raise RuntimeError("Session is not initialized")
|
|
233
|
+
|
|
234
|
+
return await self._safe_request(
|
|
235
|
+
method="get",
|
|
236
|
+
url=url,
|
|
237
|
+
params=params,
|
|
238
|
+
headers=headers,
|
|
239
|
+
cookies=cookies,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def safe_post(self,
|
|
243
|
+
url: httpx.URL | str,
|
|
244
|
+
*,
|
|
245
|
+
content: DataType | None = None,
|
|
246
|
+
data: JsonType | None = None,
|
|
247
|
+
json: Any | None = None,
|
|
248
|
+
params: ParamType | None = None,
|
|
249
|
+
headers: HeaderType | None = None,
|
|
250
|
+
cookies: CookieType | None = None,
|
|
251
|
+
) -> Response:
|
|
252
|
+
"""Execute a safe POST request with automatic retry on database lock.
|
|
253
|
+
|
|
254
|
+
Note:
|
|
255
|
+
"Safe" only means "with retries if database is locked".
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
url: The URL to request.
|
|
259
|
+
content: Request content (optional).
|
|
260
|
+
data: Form data (optional).
|
|
261
|
+
json: JSON body (optional).
|
|
262
|
+
params: Query parameters (optional).
|
|
263
|
+
headers: Request headers (optional).
|
|
264
|
+
cookies: Request cookies (optional).
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The HTTP response.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
RuntimeError: If the session is not initialized.
|
|
271
|
+
"""
|
|
272
|
+
if self.session is None:
|
|
273
|
+
raise RuntimeError("Session is not initialized")
|
|
274
|
+
|
|
275
|
+
return await self._safe_request(
|
|
276
|
+
method="post",
|
|
277
|
+
url=url,
|
|
278
|
+
content=content,
|
|
279
|
+
data=data,
|
|
280
|
+
json=json,
|
|
281
|
+
params=params,
|
|
282
|
+
headers=headers,
|
|
283
|
+
cookies=cookies,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def login(self) -> None:
|
|
287
|
+
"""Authenticate with the 3X-UI panel."""
|
|
288
|
+
payload = {
|
|
289
|
+
"username": self.xui_username,
|
|
290
|
+
"password": self.xui_password,
|
|
291
|
+
}
|
|
292
|
+
if self.totp:
|
|
293
|
+
if (
|
|
294
|
+
self.totp.interval - datetime.now().timestamp() % self.totp.interval
|
|
295
|
+
< 3
|
|
296
|
+
):
|
|
297
|
+
await asyncio.sleep(3.1) # just to not submit an invalid code
|
|
298
|
+
payload["twoFactorCode"] = self.totp.now()
|
|
299
|
+
elif self.two_fac_secret:
|
|
300
|
+
payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
|
|
301
|
+
|
|
302
|
+
logging.info(
|
|
303
|
+
"Client is logging in (panel: %s)", self.panel_id or self.base_host
|
|
304
|
+
)
|
|
305
|
+
resp = await self.session.post("/login", data=payload)
|
|
306
|
+
if resp.status_code == 200:
|
|
307
|
+
resp_json = resp.json()
|
|
308
|
+
if "success" not in resp_json:
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}"
|
|
311
|
+
)
|
|
312
|
+
if not resp_json["success"]:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"Error: wrong credentials (including status code) or failed login."
|
|
315
|
+
)
|
|
316
|
+
self.session_start = datetime.now(UTC).timestamp()
|
|
317
|
+
return
|
|
318
|
+
raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
|
|
319
|
+
|
|
320
|
+
def connect(self) -> Self:
|
|
321
|
+
"""Create an async HTTP client session."""
|
|
322
|
+
logging.log(
|
|
323
|
+
DEBUG, "Client connected (panel: %s)", self.panel_id or self.base_url
|
|
324
|
+
)
|
|
325
|
+
self.session = AsyncClient(base_url=self.base_url)
|
|
326
|
+
self.connected = True
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
async def disconnect(self) -> None:
|
|
330
|
+
"""Close the HTTP session only (no cache teardown)."""
|
|
331
|
+
self.connected = False
|
|
332
|
+
if self.session is not None:
|
|
333
|
+
await self.session.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_clients.py
RENAMED
|
File without changes
|
{python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_inbounds.py
RENAMED
|
File without changes
|
|
File without changes
|