Python-3xui 0.0.13.post2__tar.gz → 0.1.3__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.1.3/PKG-INFO +84 -0
- python_3xui-0.1.3/README.md +56 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/pyproject.toml +2 -2
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/__init__.py +1 -1
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api.py +50 -19
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api_core/client_service.py +178 -47
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api_core/session_core.py +63 -14
- python_3xui-0.1.3/python_3xui/custom_exceptions.py +66 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/models.py +19 -12
- python_3xui-0.1.3/tests/conftest.py +98 -0
- python_3xui-0.1.3/tests/test_client_service_hysteria.py +95 -0
- python_3xui-0.1.3/tests/test_models_hysteria.py +103 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/test_non_idempotent_endpoints_clients.py +9 -1
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/test_xuiclient_helpers.py +139 -11
- python_3xui-0.0.13.post2/PKG-INFO +0 -34
- python_3xui-0.0.13.post2/README.md +0 -6
- python_3xui-0.0.13.post2/python_3xui/custom_exceptions.py +0 -28
- python_3xui-0.0.13.post2/tests/conftest.py +0 -65
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/.gitignore +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/LICENSE +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api_core/__init__.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api_core/identity.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/api_core/prod_cache.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/base_model.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/endpoints.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/python_3xui/util.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/pytest.ini +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.13.post2 → python_3xui-0.1.3}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Python-3xui
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: 3x-ui wrapper for python
|
|
5
|
+
Project-URL: Homepage, https://github.com/Artem-Potapov/3x-py
|
|
6
|
+
Project-URL: Issues, https://github.com/Artem-Potapov/3x-py/issues
|
|
7
|
+
Author-email: JustMe_001 <justme001.causation755@passinbox.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: async-lru~=2.3.0
|
|
16
|
+
Requires-Dist: cachetools~=7.1.1
|
|
17
|
+
Requires-Dist: httpx~=0.28.1
|
|
18
|
+
Requires-Dist: pybase62~=1.0.0
|
|
19
|
+
Requires-Dist: pydantic<3,~=2.12.5
|
|
20
|
+
Requires-Dist: pyotp~=2.9.0
|
|
21
|
+
Requires-Dist: python-dotenv==1.2.2
|
|
22
|
+
Provides-Extra: testing
|
|
23
|
+
Requires-Dist: pytest; extra == 'testing'
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == 'testing'
|
|
25
|
+
Requires-Dist: pytest-dependency; extra == 'testing'
|
|
26
|
+
Requires-Dist: requests; extra == 'testing'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
30
|
+
<p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
|
|
31
|
+
<p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
|
|
32
|
+
|
|
33
|
+
<h2>0.1.3 Release Notes</h2>
|
|
34
|
+
Adds Hysteria2 support for inbounds and client provisioning.
|
|
35
|
+
|
|
36
|
+
<h3>New: Hysteria2 support</h3>
|
|
37
|
+
|
|
38
|
+
- `Inbound.protocol` now accepts `"hysteria"` alongside `"vless"` and `"vmess"`.
|
|
39
|
+
- `SingleInboundClient` gains an `auth` field (the Hysteria2 secret). A new `model_validator`, `ensure_auth_or_id_present`, requires either `auth` (Hysteria2) or `uuid` (everything else). `uuid` now defaults to `""`, and both fields are dropped from the serialized payload when empty.
|
|
40
|
+
- `create_and_add_prod_client` detects `hysteria` inbounds and emits a random per-client `auth` instead of the deterministic Telegram-ID-derived UUID it uses for VLESS/VMess.
|
|
41
|
+
|
|
42
|
+
<h3>Misc</h3>
|
|
43
|
+
|
|
44
|
+
- New `trust_env` constructor kwarg, forwarded to `httpx.AsyncClient`. Defaults to `True`; set `False` to make httpx ignore the environment's proxy/SSL config — handy behind a socks4 proxy httpx can't use.
|
|
45
|
+
|
|
46
|
+
<h2>0.1.1r1 Release Notes</h2>
|
|
47
|
+
First "middle" version. Brings 3X-UI 3.0.0+ panel support and a client-side workaround for the panel's new duplicate-email behavior.
|
|
48
|
+
|
|
49
|
+
<h3>New: 3.0.0+ panel auth</h3>
|
|
50
|
+
|
|
51
|
+
Three new `XUIClient` constructor kwargs:
|
|
52
|
+
|
|
53
|
+
- `use_new_schema=True` — switches `login()` to the 3.0.0+ flow. Required for any 3.0.0+ panel.
|
|
54
|
+
- `bearer_token=...` — long-lived API token from the panel. Required when `use_new_schema=True`; wrapped in `SecretStr` internally and sent as `Authorization: Bearer <...>` on every request.
|
|
55
|
+
- `force_login=True` — additive on top of bearer. Coins a CSRF token (`/csrf-token`) and runs the cookie-login on top, for routes that still need it.
|
|
56
|
+
|
|
57
|
+
`SessionCore.login()` becomes a 3-branch dispatcher; the existing 2.X.X cookie-only flow is unchanged when `use_new_schema=False`.
|
|
58
|
+
|
|
59
|
+
Three new exceptions, all re-exported via `python_3xui.exceptions`:
|
|
60
|
+
|
|
61
|
+
- `SchemaMismatchError` — constructor-time. `use_new_schema=True` with no `bearer_token`.
|
|
62
|
+
- `InvalidBearerTokenError` — runtime. A 404 in pure-bearer mode would otherwise loop into `login()` re-setting the same (bad) header; the request now fast-fails instead.
|
|
63
|
+
- `CsrfTokenNotCoined` — runtime. `/csrf-token` didn't return a usable `obj`; the panel is almost certainly down.
|
|
64
|
+
|
|
65
|
+
<h3>New: client-side duplicate-email guard</h3>
|
|
66
|
+
|
|
67
|
+
3X-UI 3.0.0+ silently accepts duplicate client emails on add instead of hard-rejecting them, which broke the `"duplicate email"` response-msg scan that drove `exist_ok` / `replace_if_exist` in `create_and_add_prod_client`. Workaround landed in this release:
|
|
68
|
+
|
|
69
|
+
- New `guard_duplicate_emails` `XUIClient` kwarg. Defaults to `use_new_schema` (on for 3.0.0+, off for 2.X.X). Pass `True`/`False` to force.
|
|
70
|
+
- New `guard_duplicates` per-call override on `create_and_add_prod_client`.
|
|
71
|
+
- When active, the method burst-probes `get_client_with_email` for every production inbound's deterministic `(tgid, inbid)` email before adding. Hits become a `conflicts: dict[int, ClientStats]` map; the panel add is skipped for those inbounds.
|
|
72
|
+
- `exist_ok`, `replace_if_exist`, and raise-on-default all keep their original semantics — the guard just feeds them. `replace_if_exist` even saves a round-trip vs the old path, since the guard already has the existing client's uuid.
|
|
73
|
+
- Return type widens to `dict[int, Response | None]`. `None` appears only with `exist_ok=True` and no `replace_if_exist`, for the inbounds the guard short-circuited.
|
|
74
|
+
|
|
75
|
+
<h3>Fixes & misc</h3>
|
|
76
|
+
|
|
77
|
+
- `SchemaMismatchError` message rendered as a tuple instead of formatted; switched the call site to an f-string.
|
|
78
|
+
- `tests/conftest.py` now requires `BEARER` in the env-var precheck; without it the fixture would raise `SchemaMismatchError` at construction instead of skipping cleanly.
|
|
79
|
+
- Stale `FIXME` comment removed from `SessionCore.login`.
|
|
80
|
+
- `cachetools` pin relaxed to `~=7.1.1`.
|
|
81
|
+
- Public exception surface now also exports `CsrfTokenNotCoined`, `SchemaMismatchError`, and `InvalidBearerTokenError` via `python_3xui.exceptions`.
|
|
82
|
+
|
|
83
|
+
<h2>0.0.12 Release Notes</h2>
|
|
84
|
+
Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
2
|
+
<p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
|
|
3
|
+
<p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
|
|
4
|
+
|
|
5
|
+
<h2>0.1.3 Release Notes</h2>
|
|
6
|
+
Adds Hysteria2 support for inbounds and client provisioning.
|
|
7
|
+
|
|
8
|
+
<h3>New: Hysteria2 support</h3>
|
|
9
|
+
|
|
10
|
+
- `Inbound.protocol` now accepts `"hysteria"` alongside `"vless"` and `"vmess"`.
|
|
11
|
+
- `SingleInboundClient` gains an `auth` field (the Hysteria2 secret). A new `model_validator`, `ensure_auth_or_id_present`, requires either `auth` (Hysteria2) or `uuid` (everything else). `uuid` now defaults to `""`, and both fields are dropped from the serialized payload when empty.
|
|
12
|
+
- `create_and_add_prod_client` detects `hysteria` inbounds and emits a random per-client `auth` instead of the deterministic Telegram-ID-derived UUID it uses for VLESS/VMess.
|
|
13
|
+
|
|
14
|
+
<h3>Misc</h3>
|
|
15
|
+
|
|
16
|
+
- New `trust_env` constructor kwarg, forwarded to `httpx.AsyncClient`. Defaults to `True`; set `False` to make httpx ignore the environment's proxy/SSL config — handy behind a socks4 proxy httpx can't use.
|
|
17
|
+
|
|
18
|
+
<h2>0.1.1r1 Release Notes</h2>
|
|
19
|
+
First "middle" version. Brings 3X-UI 3.0.0+ panel support and a client-side workaround for the panel's new duplicate-email behavior.
|
|
20
|
+
|
|
21
|
+
<h3>New: 3.0.0+ panel auth</h3>
|
|
22
|
+
|
|
23
|
+
Three new `XUIClient` constructor kwargs:
|
|
24
|
+
|
|
25
|
+
- `use_new_schema=True` — switches `login()` to the 3.0.0+ flow. Required for any 3.0.0+ panel.
|
|
26
|
+
- `bearer_token=...` — long-lived API token from the panel. Required when `use_new_schema=True`; wrapped in `SecretStr` internally and sent as `Authorization: Bearer <...>` on every request.
|
|
27
|
+
- `force_login=True` — additive on top of bearer. Coins a CSRF token (`/csrf-token`) and runs the cookie-login on top, for routes that still need it.
|
|
28
|
+
|
|
29
|
+
`SessionCore.login()` becomes a 3-branch dispatcher; the existing 2.X.X cookie-only flow is unchanged when `use_new_schema=False`.
|
|
30
|
+
|
|
31
|
+
Three new exceptions, all re-exported via `python_3xui.exceptions`:
|
|
32
|
+
|
|
33
|
+
- `SchemaMismatchError` — constructor-time. `use_new_schema=True` with no `bearer_token`.
|
|
34
|
+
- `InvalidBearerTokenError` — runtime. A 404 in pure-bearer mode would otherwise loop into `login()` re-setting the same (bad) header; the request now fast-fails instead.
|
|
35
|
+
- `CsrfTokenNotCoined` — runtime. `/csrf-token` didn't return a usable `obj`; the panel is almost certainly down.
|
|
36
|
+
|
|
37
|
+
<h3>New: client-side duplicate-email guard</h3>
|
|
38
|
+
|
|
39
|
+
3X-UI 3.0.0+ silently accepts duplicate client emails on add instead of hard-rejecting them, which broke the `"duplicate email"` response-msg scan that drove `exist_ok` / `replace_if_exist` in `create_and_add_prod_client`. Workaround landed in this release:
|
|
40
|
+
|
|
41
|
+
- New `guard_duplicate_emails` `XUIClient` kwarg. Defaults to `use_new_schema` (on for 3.0.0+, off for 2.X.X). Pass `True`/`False` to force.
|
|
42
|
+
- New `guard_duplicates` per-call override on `create_and_add_prod_client`.
|
|
43
|
+
- When active, the method burst-probes `get_client_with_email` for every production inbound's deterministic `(tgid, inbid)` email before adding. Hits become a `conflicts: dict[int, ClientStats]` map; the panel add is skipped for those inbounds.
|
|
44
|
+
- `exist_ok`, `replace_if_exist`, and raise-on-default all keep their original semantics — the guard just feeds them. `replace_if_exist` even saves a round-trip vs the old path, since the guard already has the existing client's uuid.
|
|
45
|
+
- Return type widens to `dict[int, Response | None]`. `None` appears only with `exist_ok=True` and no `replace_if_exist`, for the inbounds the guard short-circuited.
|
|
46
|
+
|
|
47
|
+
<h3>Fixes & misc</h3>
|
|
48
|
+
|
|
49
|
+
- `SchemaMismatchError` message rendered as a tuple instead of formatted; switched the call site to an f-string.
|
|
50
|
+
- `tests/conftest.py` now requires `BEARER` in the env-var precheck; without it the fixture would raise `SchemaMismatchError` at construction instead of skipping cleanly.
|
|
51
|
+
- Stale `FIXME` comment removed from `SessionCore.login`.
|
|
52
|
+
- `cachetools` pin relaxed to `~=7.1.1`.
|
|
53
|
+
- Public exception surface now also exports `CsrfTokenNotCoined`, `SchemaMismatchError`, and `InvalidBearerTokenError` via `python_3xui.exceptions`.
|
|
54
|
+
|
|
55
|
+
<h2>0.0.12 Release Notes</h2>
|
|
56
|
+
Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "Python-3xui"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
6
|
]
|
|
7
7
|
description = "3x-ui wrapper for python"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
classifiers = [
|
|
12
12
|
"Programming Language :: Python :: 3",
|
|
13
13
|
"Operating System :: OS Independent",
|
|
@@ -49,8 +49,10 @@ class XUIClient:
|
|
|
49
49
|
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
50
50
|
*, username: str | None = None, password: str | None = None,
|
|
51
51
|
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
52
|
+
use_new_schema: bool = False, force_login: bool = False, bearer_token: str|None=None,
|
|
53
|
+
guard_duplicate_emails: bool | None = None,
|
|
52
54
|
custom_prod_string: str = "testing",
|
|
53
|
-
max_retries: int = 5, retry_delay: int = 1,
|
|
55
|
+
max_retries: int = 5, retry_delay: int = 1, trust_env: bool = True,
|
|
54
56
|
custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
55
57
|
custom_uuid_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.get_uuid_from_tgid,
|
|
56
58
|
panel_id: Any = None
|
|
@@ -63,12 +65,24 @@ class XUIClient:
|
|
|
63
65
|
base_path: The base path for the API (e.g., "/panel").
|
|
64
66
|
username: Username for authentication.
|
|
65
67
|
password: Password for authentication.
|
|
66
|
-
two_fac_code: TOTP secret for 2FA. Short one-shot codes
|
|
67
|
-
|
|
68
|
+
two_fac_code: TOTP secret for 2FA. Short one-shot codes will \
|
|
69
|
+
work for the current login only.
|
|
70
|
+
use_new_schema: for 3X-UI 3.0.0 and above. Cardinally changes how logging in works.
|
|
71
|
+
force_login: Because of new schema in 3.0.0+, bearer is prevalent and login() is almost obsolete. \
|
|
72
|
+
Set to True if you need niche methods that still uses cookie-login. Omit for 2.X.X.
|
|
73
|
+
bearer_token: the bearer itself. Omit for 3X-UI 2.X.X.
|
|
74
|
+
guard_duplicate_emails: Client-side check for duplicate client emails
|
|
75
|
+
before adding (workaround for 3X-UI 3.0.0+ no longer hard-rejecting
|
|
76
|
+
duplicates). When None (default), resolves to ``use_new_schema``: on
|
|
77
|
+
for 3.0.0+ panels, off for 2.X.X. Pass True/False to force.
|
|
68
78
|
session_duration: Maximum session duration in seconds. Defaults to 3600.
|
|
69
79
|
custom_prod_string: Regex pattern used to select production inbounds.
|
|
70
80
|
max_retries: Maximum retries for database-lock responses.
|
|
71
81
|
retry_delay: Seconds to wait between database-lock retries.
|
|
82
|
+
trust_env: Forwarded to ``httpx.AsyncClient``. True (default) lets
|
|
83
|
+
httpx read proxy/SSL config from the environment (including the OS
|
|
84
|
+
proxy); set False to ignore it — e.g. behind a socks4 proxy httpx
|
|
85
|
+
cannot use.
|
|
72
86
|
custom_sub_generator: Sync or async callable that receives a
|
|
73
87
|
Telegram ID and returns the subscription ID for new clients.
|
|
74
88
|
custom_uuid_generator: Sync or async callable that receives a
|
|
@@ -83,8 +97,12 @@ class XUIClient:
|
|
|
83
97
|
password=password,
|
|
84
98
|
two_fac_code=two_fac_code,
|
|
85
99
|
session_duration=session_duration,
|
|
100
|
+
use_new_schema=use_new_schema,
|
|
101
|
+
force_login=force_login,
|
|
102
|
+
bearer_token=bearer_token,
|
|
86
103
|
max_retries=max_retries,
|
|
87
104
|
retry_delay=retry_delay,
|
|
105
|
+
trust_env=trust_env,
|
|
88
106
|
panel_id=panel_id,
|
|
89
107
|
)
|
|
90
108
|
self._identity = IdentityResolver(custom_sub_generator, custom_uuid_generator)
|
|
@@ -101,11 +119,15 @@ class XUIClient:
|
|
|
101
119
|
)
|
|
102
120
|
self.PROD_STRING = self._prod_cache.PROD_STRING
|
|
103
121
|
self.get_production_inbounds = self._prod_cache.get
|
|
122
|
+
_resolved_guard = (
|
|
123
|
+
use_new_schema if guard_duplicate_emails is None else guard_duplicate_emails
|
|
124
|
+
)
|
|
104
125
|
self._tg_client_service = TgIDClientService(
|
|
105
126
|
self.clients_end,
|
|
106
127
|
self.inbounds_end,
|
|
107
128
|
self._identity,
|
|
108
129
|
self._prod_cache,
|
|
130
|
+
guard_duplicate_emails=_resolved_guard,
|
|
109
131
|
)
|
|
110
132
|
|
|
111
133
|
@property
|
|
@@ -367,7 +389,8 @@ class XUIClient:
|
|
|
367
389
|
expiry_time: int = 0,
|
|
368
390
|
exist_ok: bool = False,
|
|
369
391
|
replace_if_exist: bool = False,
|
|
370
|
-
|
|
392
|
+
guard_duplicates: bool | None = None,
|
|
393
|
+
) -> dict[int, Response | None]:
|
|
371
394
|
"""Create and add a production client.
|
|
372
395
|
|
|
373
396
|
This method creates a new client with the given Telegram ID and
|
|
@@ -380,23 +403,29 @@ class XUIClient:
|
|
|
380
403
|
telegram_id: The Telegram ID of the client.
|
|
381
404
|
additional_remark: An optional additional remark for the client.
|
|
382
405
|
expiry_time: Expiry time in SECONDS as a UNIX timestamp.
|
|
383
|
-
exist_ok: If True, return API responses even when
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
(
|
|
388
|
-
|
|
389
|
-
|
|
406
|
+
exist_ok: If True, return API responses even when a duplicate is
|
|
407
|
+
detected. Inbounds where the guard short-circuited the add
|
|
408
|
+
appear in the result dict with a ``None`` value.
|
|
409
|
+
replace_if_exist: If True, inbounds with an existing client
|
|
410
|
+
(either guard-detected or panel-reported) will have that
|
|
411
|
+
client updated via ``request_update_client`` instead of
|
|
412
|
+
raising. The guard-detected path skips
|
|
413
|
+
``_resolve_update_client`` because the uuid is already known.
|
|
414
|
+
guard_duplicates: Per-call override for the instance-level
|
|
415
|
+
``guard_duplicate_emails`` flag. ``None`` (default) uses the
|
|
416
|
+
instance default; ``True`` / ``False`` force the guard on
|
|
417
|
+
or off for this call.
|
|
390
418
|
|
|
391
419
|
Returns:
|
|
392
|
-
Dict[int, Response]: A mapping of inbound IDs to API
|
|
393
|
-
|
|
394
|
-
add
|
|
395
|
-
|
|
420
|
+
Dict[int, Response | None]: A mapping of inbound IDs to API
|
|
421
|
+
responses. ``None`` appears only when the guard short-circuited
|
|
422
|
+
the add for that inbound and ``replace_if_exist`` did not claim
|
|
423
|
+
it (reachable only with ``exist_ok=True``).
|
|
396
424
|
|
|
397
425
|
Raises:
|
|
398
|
-
ClientEmailAlreadyExistsError:
|
|
399
|
-
|
|
426
|
+
ClientEmailAlreadyExistsError: A duplicate was detected (by the
|
|
427
|
+
guard or by panel response) and neither ``exist_ok`` nor
|
|
428
|
+
``replace_if_exist`` covered it.
|
|
400
429
|
"""
|
|
401
430
|
return await self._tg_client_service.create_and_add_prod_client(
|
|
402
431
|
telegram_id,
|
|
@@ -404,6 +433,7 @@ class XUIClient:
|
|
|
404
433
|
expiry_time=expiry_time,
|
|
405
434
|
exist_ok=exist_ok,
|
|
406
435
|
replace_if_exist=replace_if_exist,
|
|
436
|
+
guard_duplicates=guard_duplicates,
|
|
407
437
|
)
|
|
408
438
|
|
|
409
439
|
async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
|
|
@@ -487,9 +517,10 @@ class XUIClient:
|
|
|
487
517
|
enable: Whether the client is enabled (optional)
|
|
488
518
|
sub_id: Subscription ID (optional)
|
|
489
519
|
comment: Client comment/note (optional)
|
|
490
|
-
email: New client email (optional). USE WITH CAUTION BECAUSE THE XUIClient WILL NOT TRACK THE NEW EMAIL.
|
|
520
|
+
email: New client email (optional). USE WITH CAUTION BECAUSE THE XUIClient WILL **NOT** TRACK THE NEW EMAIL.
|
|
491
521
|
verbose: Enables guardrails.
|
|
492
|
-
force_resolve_by_email: Whether to enable fetch-thru-email fallback when a client is not found,
|
|
522
|
+
force_resolve_by_email: Whether to enable fetch-thru-email fallback when a client is not found, \
|
|
523
|
+
uses ~3 extra fetches but provides an extra layer of protection.
|
|
493
524
|
Returns:
|
|
494
525
|
Response from the API
|
|
495
526
|
"""
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
+
import random
|
|
7
8
|
from asyncio import Task
|
|
8
9
|
from datetime import datetime, UTC
|
|
9
10
|
from typing import TYPE_CHECKING, List, Literal
|
|
@@ -24,18 +25,22 @@ if TYPE_CHECKING:
|
|
|
24
25
|
class TgIDClientService:
|
|
25
26
|
"""Orchestrates TGID-derived flows against panel endpoints."""
|
|
26
27
|
|
|
27
|
-
__slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache"
|
|
28
|
+
__slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache",
|
|
29
|
+
"_guard_duplicate_emails")
|
|
28
30
|
|
|
29
31
|
def __init__(self,
|
|
30
32
|
clients_endpoint: Clients,
|
|
31
33
|
inbounds_endpoint: Inbounds,
|
|
32
34
|
identity: IdentityResolver,
|
|
33
35
|
prod_cache: ProductionInboundCache,
|
|
36
|
+
*,
|
|
37
|
+
guard_duplicate_emails: bool = False,
|
|
34
38
|
) -> None:
|
|
35
39
|
self._clients = clients_endpoint
|
|
36
40
|
self._inbounds = inbounds_endpoint
|
|
37
41
|
self._identity = identity
|
|
38
42
|
self._prod_cache = prod_cache
|
|
43
|
+
self._guard_duplicate_emails = guard_duplicate_emails
|
|
39
44
|
|
|
40
45
|
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
|
|
41
46
|
#FIXME: Implement the fallback described in the docstring (api.py)
|
|
@@ -52,19 +57,68 @@ class TgIDClientService:
|
|
|
52
57
|
expiry_time: int = 0,
|
|
53
58
|
exist_ok: bool = False,
|
|
54
59
|
replace_if_exist: bool = False,
|
|
55
|
-
|
|
60
|
+
guard_duplicates: bool | None = None,
|
|
61
|
+
) -> dict[int, Response | None]:
|
|
62
|
+
"""Create and add a client across every production inbound.
|
|
63
|
+
|
|
64
|
+
Phase 0 (when the guard is active) probes each production inbound
|
|
65
|
+
for the deterministic ``(tgid, inbid)`` email; any hit becomes a
|
|
66
|
+
recorded conflict. Phase 1 burst-adds only non-conflicting
|
|
67
|
+
inbounds. Phase 2 (``replace_if_exist``) updates conflicting
|
|
68
|
+
inbounds in place. Phase 3 (``not exist_ok``) raises on any
|
|
69
|
+
conflict that was not claimed by Phase 2.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
telegram_id: TG ID used to derive the client uuid and per-inbound email.
|
|
73
|
+
additional_remark: Optional remark prefix for the client's comment field.
|
|
74
|
+
expiry_time: UNIX timestamp in seconds. 0 = never expire.
|
|
75
|
+
exist_ok: When True, swallow duplicate-email conflicts; the result dict
|
|
76
|
+
will contain ``None`` for inbounds where the guard short-circuited
|
|
77
|
+
the add.
|
|
78
|
+
replace_if_exist: When True, conflicting inbounds are updated via
|
|
79
|
+
``request_update_client`` using the existing client's uuid.
|
|
80
|
+
guard_duplicates: Per-call override for the instance-level
|
|
81
|
+
``guard_duplicate_emails`` flag. ``None`` (default) uses the
|
|
82
|
+
instance default.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Mapping of inbound id to the Response from the panel.
|
|
86
|
+
Entries are ``None`` only when the guard short-circuited the
|
|
87
|
+
add for that inbound and ``replace_if_exist`` did not claim it
|
|
88
|
+
(only reachable with ``exist_ok=True``).
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ClientEmailAlreadyExistsError: A duplicate was detected (by the
|
|
92
|
+
guard or by panel response) and neither ``exist_ok`` nor
|
|
93
|
+
``replace_if_exist`` covered it.
|
|
94
|
+
"""
|
|
95
|
+
guard_active = (
|
|
96
|
+
guard_duplicates if guard_duplicates is not None
|
|
97
|
+
else self._guard_duplicate_emails
|
|
98
|
+
)
|
|
99
|
+
|
|
56
100
|
production_inbounds: tuple[Inbound, ...] = await self._prod_cache.get()
|
|
57
101
|
|
|
58
102
|
custom_sub = await self._identity.resolve_sub(telegram_id)
|
|
59
|
-
|
|
103
|
+
base_uuid = await self._identity.resolve_uuid(telegram_id)
|
|
60
104
|
|
|
61
|
-
#
|
|
62
|
-
_to_exec: list[asyncio.Task[Response]] = []
|
|
105
|
+
# Build one client template per inbound (used by Phase 1 and Phase 2).
|
|
63
106
|
clients_by_inbound: dict[int, SingleInboundClient] = {}
|
|
64
107
|
for inb in production_inbounds:
|
|
65
108
|
tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
|
|
66
|
-
|
|
109
|
+
if inb.protocol == "hysteria":
|
|
110
|
+
# Hysteria2 authenticates with a per-client secret, not a UUID.
|
|
111
|
+
#randomer = better, plus not to waste resources
|
|
112
|
+
auth = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=12))
|
|
113
|
+
uuid = ""
|
|
114
|
+
else:
|
|
115
|
+
# Reset per inbound so a preceding hysteria inbound can't leak
|
|
116
|
+
# its empty UUID / random auth onto this (non-hysteria) one.
|
|
117
|
+
auth = ""
|
|
118
|
+
uuid = base_uuid
|
|
119
|
+
clients_by_inbound[inb.id] = SingleInboundClient(
|
|
67
120
|
uuid=uuid,
|
|
121
|
+
auth=auth, #the empty one gets excluded, so it's fine
|
|
68
122
|
flow="",
|
|
69
123
|
email=tmp_email,
|
|
70
124
|
limit_gb=0,
|
|
@@ -73,62 +127,117 @@ class TgIDClientService:
|
|
|
73
127
|
comment=f"{additional_remark + ', ' if additional_remark else ''}created at {datetime.now(UTC)}",
|
|
74
128
|
expiry_time=expiry_time,
|
|
75
129
|
)
|
|
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
130
|
|
|
88
|
-
# --- Phase
|
|
131
|
+
# --- Phase 0: guard lookup (parallel email probes) ---
|
|
132
|
+
conflicts: dict[int, ClientStats] = {}
|
|
133
|
+
if guard_active:
|
|
134
|
+
_probe_tasks: list[asyncio.Task[ClientStats | None]] = []
|
|
135
|
+
_probe_order: list[int] = []
|
|
136
|
+
for inb in production_inbounds:
|
|
137
|
+
tmp_email = clients_by_inbound[inb.id].email
|
|
138
|
+
_probe_tasks.append(asyncio.create_task(
|
|
139
|
+
self._detect_email_in_inbound(tmp_email, inb.id)
|
|
140
|
+
))
|
|
141
|
+
_probe_order.append(inb.id)
|
|
142
|
+
_probe_results: list[ClientStats | None] = await asyncio.gather(*_probe_tasks)
|
|
143
|
+
for i, inb_id in enumerate(_probe_order):
|
|
144
|
+
if _probe_results[i] is not None:
|
|
145
|
+
conflicts[inb_id] = _probe_results[i]
|
|
146
|
+
|
|
147
|
+
# --- Phase 1: burst-add for clean inbounds; seed None for conflicts ---
|
|
148
|
+
responses: dict[int, Response | None] = {inb_id: None for inb_id in conflicts}
|
|
149
|
+
|
|
150
|
+
_add_tasks: list[asyncio.Task[Response]] = []
|
|
151
|
+
_add_order: list[int] = []
|
|
152
|
+
for inb in production_inbounds:
|
|
153
|
+
if inb.id in conflicts:
|
|
154
|
+
continue
|
|
155
|
+
_add_tasks.append(asyncio.create_task(
|
|
156
|
+
self._clients.add_client(clients_by_inbound[inb.id], inb.id)
|
|
157
|
+
))
|
|
158
|
+
_add_order.append(inb.id)
|
|
159
|
+
if _add_tasks:
|
|
160
|
+
_add_results: list[Response] = await asyncio.gather(*_add_tasks)
|
|
161
|
+
for i, inb_id in enumerate(_add_order):
|
|
162
|
+
responses[inb_id] = _add_results[i]
|
|
163
|
+
|
|
164
|
+
# --- Phase 2: replace_if_exist (handles both guard-detected
|
|
165
|
+
# and panel-reported duplicates) ---
|
|
89
166
|
if replace_if_exist:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
167
|
+
_update_tasks: list[asyncio.Task[Response]] = []
|
|
168
|
+
_update_order: list[int] = []
|
|
169
|
+
|
|
170
|
+
# Guard-detected conflicts: stashed stats.uuid is the existing client's uuid.
|
|
171
|
+
for inb_id, stats in conflicts.items():
|
|
172
|
+
client = clients_by_inbound[inb_id]
|
|
173
|
+
_update_tasks.append(asyncio.create_task(
|
|
174
|
+
self._clients.request_update_client(
|
|
175
|
+
client, inb_id, original_uuid=stats.uuid,
|
|
176
|
+
)
|
|
177
|
+
))
|
|
178
|
+
_update_order.append(inb_id)
|
|
93
179
|
|
|
180
|
+
# Panel-reported duplicates from Phase 1 (still relevant on 2.X.X or
|
|
181
|
+
# any future panel that re-enables hard reject).
|
|
94
182
|
for inb_id, resp in list(responses.items()):
|
|
183
|
+
if inb_id in conflicts:
|
|
184
|
+
continue # already queued above
|
|
185
|
+
if resp is None:
|
|
186
|
+
continue # only conflict-seeded entries are None at this point
|
|
95
187
|
json_resp = resp.json()
|
|
96
188
|
msg = json_resp.get("msg", "")
|
|
97
189
|
if "duplicate email" in msg.lower():
|
|
98
190
|
client = clients_by_inbound[inb_id]
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
client, inb_id, original_uuid=client.uuid,
|
|
103
|
-
)
|
|
191
|
+
_update_tasks.append(asyncio.create_task(
|
|
192
|
+
self._clients.request_update_client(
|
|
193
|
+
client, inb_id, original_uuid=client.uuid,
|
|
104
194
|
)
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for i, inb_id in enumerate(
|
|
113
|
-
_resp =
|
|
114
|
-
|
|
195
|
+
))
|
|
196
|
+
_update_order.append(inb_id)
|
|
197
|
+
|
|
198
|
+
if _update_tasks:
|
|
199
|
+
_update_results: list[Response] = await asyncio.gather(*_update_tasks)
|
|
200
|
+
_resolve_tasks: list[asyncio.Task[Response]] = []
|
|
201
|
+
_resolve_order: list[int] = []
|
|
202
|
+
for i, inb_id in enumerate(_update_order):
|
|
203
|
+
_resp = _update_results[i]
|
|
204
|
+
if inb_id in conflicts:
|
|
205
|
+
# Guard-fed updates carried a valid uuid; no "empty client id" fallback needed.
|
|
206
|
+
if not _resp.json().get("success", True):
|
|
207
|
+
logging.warning(
|
|
208
|
+
"Guard-detected update for inbound %s returned failure: %s",
|
|
209
|
+
inb_id, _resp.json().get("msg"),
|
|
210
|
+
)
|
|
211
|
+
responses[inb_id] = _resp
|
|
212
|
+
continue
|
|
213
|
+
_msg = _resp.json().get("msg", "")
|
|
115
214
|
if "empty client id" in _msg.lower():
|
|
116
|
-
|
|
117
|
-
self._resolve_update_client(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
215
|
+
_resolve_tasks.append(asyncio.create_task(
|
|
216
|
+
self._resolve_update_client(
|
|
217
|
+
telegram_id, inb_id, clients_by_inbound[inb_id]
|
|
218
|
+
)
|
|
219
|
+
))
|
|
220
|
+
_resolve_order.append(inb_id)
|
|
121
221
|
else:
|
|
122
222
|
responses[inb_id] = _resp
|
|
223
|
+
if _resolve_tasks:
|
|
224
|
+
_resolve_results: list[Response] = await asyncio.gather(*_resolve_tasks)
|
|
225
|
+
for i, inb_id in enumerate(_resolve_order):
|
|
226
|
+
responses[inb_id] = _resolve_results[i]
|
|
123
227
|
|
|
124
|
-
|
|
125
|
-
await asyncio.gather(*_search_update_exec)
|
|
126
|
-
for inb_id, task in _search_update_resp.items():
|
|
127
|
-
responses[inb_id] = task.result()
|
|
128
|
-
|
|
129
|
-
# --- Phase 3: raise on remaining duplicates if not exist_ok ---
|
|
228
|
+
# --- Phase 3: raise on unclaimed duplicates if not exist_ok ---
|
|
130
229
|
if not exist_ok:
|
|
131
230
|
for inb_id, resp in responses.items():
|
|
231
|
+
if resp is None:
|
|
232
|
+
# Guard-detected conflict that Phase 2 did not claim.
|
|
233
|
+
logging.error(
|
|
234
|
+
"Client already exists in inbound %s for tgid %s (guard-detected)",
|
|
235
|
+
inb_id, telegram_id,
|
|
236
|
+
)
|
|
237
|
+
raise ClientEmailAlreadyExistsError(
|
|
238
|
+
f"Client already exists in inbound {inb_id} for "
|
|
239
|
+
f"telegram_id {telegram_id} (guard-detected)"
|
|
240
|
+
)
|
|
132
241
|
json_resp: dict = resp.json()
|
|
133
242
|
msg = json_resp.get("msg", "")
|
|
134
243
|
if "duplicate email" in msg.lower():
|
|
@@ -149,6 +258,28 @@ class TgIDClientService:
|
|
|
149
258
|
inbound_client, inb_id, original_uuid=_found.uuid,
|
|
150
259
|
)
|
|
151
260
|
|
|
261
|
+
async def _detect_email_in_inbound(self,
|
|
262
|
+
email: str,
|
|
263
|
+
inbound_id: int,
|
|
264
|
+
) -> ClientStats | None:
|
|
265
|
+
"""Return the panel's ClientStats record for ``email`` if it lives
|
|
266
|
+
in ``inbound_id``, else None. Used by the duplicate-email guard
|
|
267
|
+
before burst-add.
|
|
268
|
+
|
|
269
|
+
The returned ``ClientStats`` carries the existing client's
|
|
270
|
+
``uuid``, which the guard's ``replace_if_exist`` branch reuses to
|
|
271
|
+
skip a round-trip through ``_resolve_update_client``.
|
|
272
|
+
|
|
273
|
+
Exceptions from ``get_client_with_email`` (network, auth,
|
|
274
|
+
validation) propagate unchanged.
|
|
275
|
+
"""
|
|
276
|
+
stats = await self._clients.get_client_with_email(
|
|
277
|
+
email, raise_if_none=False
|
|
278
|
+
)
|
|
279
|
+
if stats is not None and stats.inboundId == inbound_id:
|
|
280
|
+
return stats
|
|
281
|
+
return None
|
|
282
|
+
|
|
152
283
|
async def _find_client_in_inbound(self,
|
|
153
284
|
client_uuid: str,
|
|
154
285
|
inbound_id: int,
|