From 48abeb04d54c9d99f71aa4d752bcafe1b79367d6 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Tue, 22 Sep 2026 21:10:04 +0000 Subject: [PATCH] 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 --- aggregator.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/aggregator.py b/aggregator.py index c0eba2e..3c0b52d 100644 --- a/aggregator.py +++ b/aggregator.py @@ -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: