feat(nl): token-based API support for 3x-ui 3.8.5 (api_token)

- add api_token field handling for NL (new panel uses Bearer token, not cookie)
- modern clients API: POST /panel/api/clients/add and bulkDel via token
- fetch sub_ids via token, support find/traffic via token
- fixes create/delete for NL where py3xui login fails due to CSRF
This commit is contained in:
SashegDev
2026-09-22 21:10:04 +00:00
parent 814f103bb4
commit 48abeb04d5
+140
View File
@@ -471,8 +471,24 @@ def get_traffic_stats(sub_id: str) -> dict:
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
api_token = inbound.get("api_token")
inbound_id = inbound.get("id")
if api_token:
# token-based traffic: try modern API, fallback to 0
try:
import httpx as _httpx
with _httpx.Client(verify=False, timeout=10.0) as c:
r = c.get(f"{api_host.rstrip('/')}/panel/api/clients/list", headers=_token_headers(api_token))
data = r.json()
for cl in data.get("obj", []):
if (cl.get("subId") or cl.get("sub_id")) == sub_id and inbound_id in (cl.get("inboundIds") or []):
srv_up += cl.get("up", 0) or 0
srv_down += cl.get("down", 0) or 0
except:
pass
continue
if not all([api_host, api_user, api_pass, inbound_id]):
continue
@@ -579,8 +595,29 @@ def find_user_on_servers(sub_id: str) -> Optional[str]:
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
api_token = inbound.get("api_token")
inbound_id = inbound.get("id")
if api_token:
try:
import httpx as _httpx
with _httpx.Client(verify=False, timeout=10.0) as c:
r = c.get(f"{api_host.rstrip('/')}/panel/api/clients/list", headers=_token_headers(api_token))
data = r.json()
for cl in data.get("obj", []):
sid = cl.get("subId") or cl.get("sub_id") or ""
if sid == sub_id and inbound_id in (cl.get("inboundIds") or []):
email = cl.get("email") or ""
if '@' in email:
username = email.split('@')[0]
if '_' in username:
username = username.split('_')[0]
logger.info(f"Migrated user: {username} (found on {srv['name']}/{inbound['name']} via token)")
return username
except Exception as e:
logger.warning(f"Skipping unavailable server {srv['name']}/{inbound['name']} (token): {e}")
continue
if not all([api_host, api_user, api_pass, inbound_id]):
continue
@@ -1471,12 +1508,98 @@ def _hysteria_update_inbound(api_host: str, api_user: str, api_pass: str, inboun
"tag": raw_obj.get("tag", "")
})
def _token_headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _fetch_via_token(api_host: str, api_token: str, inbound_id: int) -> set:
try:
import httpx as _httpx
url = f"{api_host.rstrip('/')}/panel/api/clients/list"
with _httpx.Client(verify=False, timeout=15.0) as c:
r = c.get(url, headers=_token_headers(api_token))
data = r.json()
if not data.get("success"):
return set()
ids = set()
for cl in data.get("obj", []):
if inbound_id in (cl.get("inboundIds") or []):
sid = cl.get("subId") or cl.get("sub_id") or ""
if sid:
ids.add(sid)
return ids
except Exception as e:
logger.warning(f"token fetch failed {api_host}: {e}")
return set()
def _create_via_token(api_host: str, api_token: str, inbound: dict, username: str, sub_id: str, traffic_gb: int) -> dict:
try:
import httpx as _httpx
inbound_id = inbound.get("id")
inbound_name = inbound.get("name", "default")
url = f"{api_host.rstrip('/')}/panel/api/clients/add"
with _httpx.Client(verify=False, timeout=15.0) as c:
r = c.get(f"{api_host.rstrip('/')}/panel/api/clients/list", headers=_token_headers(api_token))
data = r.json()
for cl in data.get("obj", []):
if cl.get("subId") == sub_id and inbound_id in (cl.get("inboundIds") or []):
return {"success": True, "email": cl.get("email", "")}
client_uuid = str(uuid.uuid4())
email = f"{username}_{inbound_name}@vless.local"
payload = {
"client": {
"email": email,
"id": client_uuid,
"subId": sub_id,
"enable": True,
"limitIp": 0,
"totalGB": traffic_gb * 1073741824 if traffic_gb > 0 else 0,
"expiryTime": 0,
"tgId": 0
},
"inboundIds": [inbound_id]
}
r2 = c.post(url, json=payload, headers=_token_headers(api_token))
j = r2.json()
if j.get("success"):
return {"success": True, "email": email}
return {"success": False, "error": j.get("msg", str(j))[:150]}
except Exception as e:
return {"success": False, "error": str(e)[:150]}
def _delete_via_token(api_host: str, api_token: str, sub_id: str) -> dict:
try:
import httpx as _httpx
with _httpx.Client(verify=False, timeout=15.0) as c:
# find email by subId
r = c.get(f"{api_host.rstrip('/')}/panel/api/clients/list", headers=_token_headers(api_token))
data = r.json()
emails = []
for cl in data.get("obj", []):
if (cl.get("subId") or cl.get("sub_id")) == sub_id:
emails.append(cl.get("email"))
if not emails:
return {"success": True, "error": "not_found"}
url = f"{api_host.rstrip('/')}/panel/api/clients/bulkDel"
r2 = c.post(url, json={"emails": emails}, headers=_token_headers(api_token))
j = r2.json()
if j.get("success"):
return {"success": True}
return {"success": False, "error": j.get("msg", str(j))[:100]}
except Exception as e:
return {"success": False, "error": str(e)[:100]}
def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: int = 0) -> dict:
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
api_token = inbound.get("api_token")
inbound_id = inbound.get("id")
if api_token:
if not all([api_host, api_token, inbound_id]):
return {"success": False, "error": "missing_credentials"}
return _create_via_token(api_host, api_token, inbound, username, sub_id, traffic_gb)
if not all([api_host, api_user, api_pass, inbound_id]):
return {"success": False, "error": "missing_credentials"}
@@ -1570,8 +1693,14 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict:
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
api_token = inbound.get("api_token")
inbound_id = inbound.get("id")
if api_token:
if not all([api_host, api_token, inbound_id]):
return {"success": False, "error": "missing_credentials"}
return _delete_via_token(api_host, api_token, sub_id)
if not all([api_host, api_user, api_pass, inbound_id]):
return {"success": False, "error": "missing_credentials"}
@@ -1622,8 +1751,19 @@ def fetch_server_sub_ids(srv: dict) -> dict:
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
api_token = inbound.get("api_token")
inbound_id = inbound.get("id")
ib_name = inbound.get("name", "?")
if api_token:
if not all([api_host, api_token, inbound_id]):
continue
try:
fetched = _fetch_via_token(api_host, api_token, inbound_id)
result[inbound_id] = fetched
logger.info(f"fetch_server_sub_ids {srv_name}/{ib_name}: got {len(fetched)} sub_ids (token)")
except Exception as e:
logger.warning(f"fetch_server_sub_ids {srv_name}/{ib_name} token error: {e}")
continue
if not all([api_host, api_user, api_pass, inbound_id]):
continue
try: