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:
+140
@@ -471,8 +471,24 @@ def get_traffic_stats(sub_id: str) -> dict:
|
|||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
|
api_token = inbound.get("api_token")
|
||||||
inbound_id = inbound.get("id")
|
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]):
|
if not all([api_host, api_user, api_pass, inbound_id]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -579,8 +595,29 @@ def find_user_on_servers(sub_id: str) -> Optional[str]:
|
|||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
|
api_token = inbound.get("api_token")
|
||||||
inbound_id = inbound.get("id")
|
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]):
|
if not all([api_host, api_user, api_pass, inbound_id]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1471,12 +1508,98 @@ def _hysteria_update_inbound(api_host: str, api_user: str, api_pass: str, inboun
|
|||||||
"tag": raw_obj.get("tag", "")
|
"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:
|
def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: int = 0) -> dict:
|
||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
|
api_token = inbound.get("api_token")
|
||||||
inbound_id = inbound.get("id")
|
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]):
|
if not all([api_host, api_user, api_pass, inbound_id]):
|
||||||
return {"success": False, "error": "missing_credentials"}
|
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_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
|
api_token = inbound.get("api_token")
|
||||||
inbound_id = inbound.get("id")
|
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]):
|
if not all([api_host, api_user, api_pass, inbound_id]):
|
||||||
return {"success": False, "error": "missing_credentials"}
|
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_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
|
api_token = inbound.get("api_token")
|
||||||
inbound_id = inbound.get("id")
|
inbound_id = inbound.get("id")
|
||||||
ib_name = inbound.get("name", "?")
|
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]):
|
if not all([api_host, api_user, api_pass, inbound_id]):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user