3x-ui: add expiry days and start-after-first-use for clients

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 22:18:37 +03:00
co-authored by Cursor
parent 1626c16df0
commit 1b2fea4539
4 changed files with 68 additions and 9 deletions
+7 -1
View File
@@ -169,6 +169,8 @@ async def xui_add_client(
inbound_id: int = Form(...), inbound_id: int = Form(...),
total_gb: int = Form(0), total_gb: int = Form(0),
limit_ip: int = Form(0), limit_ip: int = Form(0),
expiry_days: int = Form(0),
start_after_first_use: str = Form(""),
flow: str = Form(""), flow: str = Form(""),
comment: str = Form(""), comment: str = Form(""),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -187,10 +189,11 @@ async def xui_add_client(
inbound_id=inbound_id, inbound_id=inbound_id,
total_gb=total_gb, total_gb=total_gb,
limit_ip=limit_ip, limit_ip=limit_ip,
expiry_days=expiry_days,
start_after_first_use=start_after_first_use == "1",
flow=flow.strip(), flow=flow.strip(),
comment=comment.strip(), comment=comment.strip(),
) )
# Keep redirect short: store essentials only
slim = { slim = {
"protocol": result.get("protocol"), "protocol": result.get("protocol"),
"email": result.get("email"), "email": result.get("email"),
@@ -201,6 +204,9 @@ async def xui_add_client(
"publicKey": result.get("publicKey"), "publicKey": result.get("publicKey"),
"links": result.get("links") or [], "links": result.get("links") or [],
"inbound": result.get("inbound"), "inbound": result.get("inbound"),
"expiry_days": result.get("expiry_days"),
"start_after_first_use": result.get("start_after_first_use"),
"expiryTime": result.get("expiryTime"),
} }
payload = quote(json.dumps(slim, ensure_ascii=False)) payload = quote(json.dumps(slim, ensure_ascii=False))
return RedirectResponse( return RedirectResponse(
+4 -4
View File
@@ -264,7 +264,6 @@ class XuiClient:
raise XuiApiError(f"Inbound #{inbound_id} — не VLESS (protocol={proto})") raise XuiApiError(f"Inbound #{inbound_id} — не VLESS (protocol={proto})")
uuid = await self.get_new_uuid() uuid = await self.get_new_uuid()
# Auto vision flow for Reality TCP if caller left flow empty
if not flow: if not flow:
try: try:
stream = inbound.get("streamSettings") or {} stream = inbound.get("streamSettings") or {}
@@ -285,7 +284,7 @@ class XuiClient:
"enable": True, "enable": True,
"flow": flow, "flow": flow,
"totalGB": total_gb, "totalGB": total_gb,
"expiryTime": expiry_time, "expiryTime": int(expiry_time),
"limitIp": limit_ip, "limitIp": limit_ip,
"tgId": 0, "tgId": 0,
"subId": _random_sub_id(), "subId": _random_sub_id(),
@@ -300,6 +299,7 @@ class XuiClient:
"inbound_id": inbound_id, "inbound_id": inbound_id,
"uuid": uuid, "uuid": uuid,
"flow": flow, "flow": flow,
"expiryTime": int(expiry_time),
"details": details, "details": details,
"links": links, "links": links,
} }
@@ -329,7 +329,7 @@ class XuiClient:
"allowedIPs": ["0.0.0.0/0", "::/0"], "allowedIPs": ["0.0.0.0/0", "::/0"],
"keepAlive": 0, "keepAlive": 0,
"totalGB": total_gb, "totalGB": total_gb,
"expiryTime": expiry_time, "expiryTime": int(expiry_time),
"limitIp": limit_ip, "limitIp": limit_ip,
"tgId": 0, "tgId": 0,
"subId": _random_sub_id(), "subId": _random_sub_id(),
@@ -337,13 +337,13 @@ class XuiClient:
} }
await self.add_client_raw(client, [inbound_id]) await self.add_client_raw(client, [inbound_id])
details = await self.get_client(email) details = await self.get_client(email)
# WG has no vless:// link — expose keys for conf building
return { return {
"protocol": "wireguard", "protocol": "wireguard",
"email": email, "email": email,
"inbound_id": inbound_id, "inbound_id": inbound_id,
"privateKey": keys["privateKey"], "privateKey": keys["privateKey"],
"publicKey": keys["publicKey"], "publicKey": keys["publicKey"],
"expiryTime": int(expiry_time),
"details": details, "details": details,
"inbound": { "inbound": {
"id": inbound.get("id"), "id": inbound.get("id"),
+31 -4
View File
@@ -140,6 +140,24 @@ async def load_available_inbounds(
return await client.list_available_inbounds(protocols=protocols, enabled_only=True) return await client.list_available_inbounds(protocols=protocols, enabled_only=True)
def compute_expiry_time(*, days: int, start_after_first_use: bool) -> int:
"""
3x-ui expiryTime encoding:
0 — unlimited
>0 — absolute deadline in unix ms
<0 — duration ms; countdown starts after first use
"""
days = int(days or 0)
if days <= 0:
return 0
duration_ms = days * 24 * 60 * 60 * 1000
if start_after_first_use:
return -duration_ms
import time
return int(time.time() * 1000) + duration_ms
async def add_xui_client( async def add_xui_client(
panel: XuiPanel, panel: XuiPanel,
*, *,
@@ -148,27 +166,36 @@ async def add_xui_client(
inbound_id: int, inbound_id: int,
total_gb: int = 0, total_gb: int = 0,
limit_ip: int = 0, limit_ip: int = 0,
expiry_days: int = 0,
start_after_first_use: bool = False,
flow: str = "", flow: str = "",
comment: str = "", comment: str = "",
) -> dict: ) -> dict:
proto = protocol.strip().lower() proto = protocol.strip().lower()
bytes_limit = total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0 bytes_limit = total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0
expiry_time = compute_expiry_time(days=expiry_days, start_after_first_use=start_after_first_use)
async with _client_for(panel) as client: async with _client_for(panel) as client:
if proto == "vless": if proto == "vless":
return await client.add_vless_client( result = await client.add_vless_client(
email=email, email=email,
inbound_id=inbound_id, inbound_id=inbound_id,
total_gb=bytes_limit, total_gb=bytes_limit,
expiry_time=expiry_time,
limit_ip=limit_ip, limit_ip=limit_ip,
flow=flow, flow=flow,
comment=comment, comment=comment,
) )
if proto == "wireguard": elif proto == "wireguard":
return await client.add_wireguard_client( result = await client.add_wireguard_client(
email=email, email=email,
inbound_id=inbound_id, inbound_id=inbound_id,
total_gb=bytes_limit, total_gb=bytes_limit,
expiry_time=expiry_time,
limit_ip=limit_ip, limit_ip=limit_ip,
comment=comment, comment=comment,
) )
raise ValueError("Поддерживаются только vless и wireguard") else:
raise ValueError("Поддерживаются только vless и wireguard")
result["expiry_days"] = expiry_days
result["start_after_first_use"] = start_after_first_use
return result
+26
View File
@@ -36,6 +36,12 @@
<p style="margin:0"><strong>{{ created.email }}</strong> → inbound #{{ created.inbound_id }}</p> <p style="margin:0"><strong>{{ created.email }}</strong> → inbound #{{ created.inbound_id }}</p>
{% if created.protocol == 'vless' %} {% if created.protocol == 'vless' %}
{% if created.uuid %}<p class="muted" style="margin:0">UUID: <code>{{ created.uuid }}</code>{% if created.flow %} · flow: {{ created.flow }}{% endif %}</p>{% endif %} {% if created.uuid %}<p class="muted" style="margin:0">UUID: <code>{{ created.uuid }}</code>{% if created.flow %} · flow: {{ created.flow }}{% endif %}</p>{% endif %}
{% if created.expiry_days %}
<p class="muted" style="margin:0">
Срок: {{ created.expiry_days }} дн.
{% if created.start_after_first_use %}· старт после первого использования{% else %}· с момента создания{% endif %}
</p>
{% endif %}
{% if created.links %} {% if created.links %}
{% for link in created.links %} {% for link in created.links %}
<pre class="mono">{{ link }}</pre> <pre class="mono">{{ link }}</pre>
@@ -44,6 +50,12 @@
<p class="muted">Ссылка vless:// появится в 3x-ui (Clients → Copy URL).</p> <p class="muted">Ссылка vless:// появится в 3x-ui (Clients → Copy URL).</p>
{% endif %} {% endif %}
{% elif created.protocol == 'wireguard' %} {% elif created.protocol == 'wireguard' %}
{% if created.expiry_days %}
<p class="muted" style="margin:0">
Срок: {{ created.expiry_days }} дн.
{% if created.start_after_first_use %}· старт после первого использования{% else %}· с момента создания{% endif %}
</p>
{% endif %}
<p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p> <p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p>
<pre class="mono">PrivateKey = {{ created.privateKey }} <pre class="mono">PrivateKey = {{ created.privateKey }}
PublicKey = {{ created.publicKey }} PublicKey = {{ created.publicKey }}
@@ -91,6 +103,9 @@ PublicKey = {{ created.publicKey }}
<input name="flow" placeholder="xtls-rprx-vision" /> <input name="flow" placeholder="xtls-rprx-vision" />
</label> </label>
<div class="form-grid-2"> <div class="form-grid-2">
<label>Срок действия (дни, 0 = безлимит)
<input type="number" name="expiry_days" value="30" min="0" />
</label>
<label>Лимит GB <label>Лимит GB
<input type="number" name="total_gb" value="0" min="0" /> <input type="number" name="total_gb" value="0" min="0" />
</label> </label>
@@ -98,6 +113,10 @@ PublicKey = {{ created.publicKey }}
<input type="number" name="limit_ip" value="0" min="0" /> <input type="number" name="limit_ip" value="0" min="0" />
</label> </label>
</div> </div>
<label class="check">
<input type="checkbox" name="start_after_first_use" value="1" checked />
Старт после первого использования
</label>
<label>Комментарий <label>Комментарий
<input name="comment" /> <input name="comment" />
</label> </label>
@@ -124,6 +143,9 @@ PublicKey = {{ created.publicKey }}
</select> </select>
</label> </label>
<div class="form-grid-2"> <div class="form-grid-2">
<label>Срок действия (дни, 0 = безлимит)
<input type="number" name="expiry_days" value="30" min="0" />
</label>
<label>Лимит GB <label>Лимит GB
<input type="number" name="total_gb" value="0" min="0" /> <input type="number" name="total_gb" value="0" min="0" />
</label> </label>
@@ -131,6 +153,10 @@ PublicKey = {{ created.publicKey }}
<input type="number" name="limit_ip" value="0" min="0" /> <input type="number" name="limit_ip" value="0" min="0" />
</label> </label>
</div> </div>
<label class="check">
<input type="checkbox" name="start_after_first_use" value="1" checked />
Старт после первого использования
</label>
<label>Комментарий <label>Комментарий
<input name="comment" /> <input name="comment" />
</label> </label>