feat: add Nox-API upstream support

This commit is contained in:
liumangmang
2026-06-30 11:22:38 +08:00
parent 9600e4ceba
commit a914d3d222
8 changed files with 189 additions and 33 deletions
+44 -7
View File
@@ -37,6 +37,16 @@ MASK = "***"
SECRET_KEYS = {"password", "token", "key", "secret"}
AUTH_CONFIG_ALLOWED_KEYS = {
"none": set(),
"bearer": {"token"},
"nox_token": {"token", "user_id", "new_api_user", "provider"},
"api_key": {"key", "header"},
"cookie": {"cookie_string", "new_api_user", "user_id", "provider"},
"login_password": {"email", "password", "login_path", "username_field", "new_api_user", "user_id"},
}
def _group_id(group: dict) -> str:
for key in ("id", "group_id", "groupId"):
value = group.get(key)
@@ -80,6 +90,13 @@ def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
return masked
def _normalize_auth_config(auth_type: str, cfg: dict) -> dict:
allowed = AUTH_CONFIG_ALLOWED_KEYS.get(auth_type)
if allowed is None:
return cfg
return {k: v for k, v in cfg.items() if k in allowed}
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
if not isinstance(payload, dict):
return ""
@@ -319,20 +336,39 @@ def _is_sub2api_upstream(upstream: Upstream) -> bool:
return upstream.api_prefix.strip("/") == "api/v1"
def _is_nox_api_upstream(upstream: Upstream) -> bool:
auth_config = json.loads(upstream.auth_config_json or "{}")
return (
upstream.api_prefix.strip("/") == ""
and upstream.groups_endpoint == "/api/user/self/groups"
and (
upstream.auth_type == "nox_token"
or auth_config.get("login_path") == "/api/user/login"
or bool(auth_config.get("user_id"))
)
)
def _is_new_api_user_upstream(upstream: Upstream) -> bool:
auth_config = json.loads(upstream.auth_config_json or "{}")
return (
upstream.api_prefix.strip("/") == ""
and (
upstream.groups_endpoint == "/api/user/self/groups"
or auth_config.get("login_path") == "/api/user/login"
or bool(auth_config.get("new_api_user"))
bool(auth_config.get("new_api_user"))
or (
upstream.groups_endpoint == "/api/user/self/groups"
and auth_config.get("login_path") != "/api/user/login"
)
)
)
def _supports_key_generation(upstream: Upstream) -> bool:
return _is_sub2api_upstream(upstream) or _is_new_api_user_upstream(upstream)
return (
_is_sub2api_upstream(upstream)
or _is_new_api_user_upstream(upstream)
or _is_nox_api_upstream(upstream)
)
def _ensure_group_key(
@@ -486,7 +522,7 @@ def generate_keys_by_groups(
if not u:
raise HTTPException(404, "upstream not found")
if not _supports_key_generation(u):
raise HTTPException(400, "仅支持 Sub2APINew-API 普通账号上游生成 Key")
raise HTTPException(400, "仅支持 Sub2APINew-API 普通账号或 Nox-API 上游生成 Key")
# 生成前先对账,清理远端已删除的旧 Key
try:
@@ -766,7 +802,7 @@ def create_upstream(
base_url=body.base_url.rstrip("/"),
api_prefix=body.api_prefix,
auth_type=body.auth_type,
auth_config_json=json.dumps(body.auth_config, ensure_ascii=False),
auth_config_json=json.dumps(_normalize_auth_config(body.auth_type, body.auth_config), ensure_ascii=False),
rate_endpoint=body.rate_endpoint,
groups_endpoint=body.groups_endpoint,
enabled=body.enabled,
@@ -810,7 +846,8 @@ def update_upstream(
for k, v in incoming.items():
if v != MASK: # don't overwrite with mask placeholder
existing[k] = v
u.auth_config_json = json.dumps(existing, ensure_ascii=False)
next_auth_type = data.get("auth_type", u.auth_type)
u.auth_config_json = json.dumps(_normalize_auth_config(next_auth_type, existing), ensure_ascii=False)
if "base_url" in data:
data["base_url"] = data["base_url"].rstrip("/")
for k, v in data.items():
+21 -5
View File
@@ -90,6 +90,11 @@ def _curate_candidates(
"""Scan extracted data for likely credentials with confidence scoring."""
candidates: list[dict[str, Any]] = []
if not new_api_user:
new_api_user = _find_user_header(auth_headers)
if not new_api_user:
new_api_user = _find_new_api_user(local_storage, session_storage)
# 0. 完整 Cookie Bundle(最高优先级)
# 按页面 origin 收集所有相关 cookie,包含 cf_clearance 等 Cloudflare cookie
cookie_string, cookie_names = _build_cookie_bundle(cookies, page_url)
@@ -100,6 +105,7 @@ def _curate_candidates(
}
if new_api_user:
bundle_extra["new_api_user"] = new_api_user
bundle_extra["user_id"] = new_api_user
_add(
candidates, "cookie_bundle",
f"bundle:{page_url[:60]}",
@@ -113,18 +119,21 @@ def _curate_candidates(
# 1. CDP-captured network headers (high confidence)
seen = set()
for h in auth_headers:
htype = h.get("type", "authorization")
if htype == "user_id":
continue
dedup_key = h["value"]
if dedup_key in seen:
continue
seen.add(dedup_key)
htype = h.get("type", "authorization")
preview = _preview(h["value"])
if htype == "api_key":
_add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview,
f"X-API-Key — {h['url'][:40]}", 95)
else:
_add(candidates, "bearer_token", f"network:{h['url'][:60]}", h["value"], preview,
f"Authorization — {h['url'][:40]}", 95)
f"Authorization — {h['url'][:40]}", 95,
extra={"new_api_user": new_api_user, "user_id": new_api_user} if new_api_user else None)
# 2. localStorage/sessionStorage items
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
@@ -155,9 +164,6 @@ def _curate_candidates(
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
f"{store_name}.{key} (API Key)", 90)
if not new_api_user:
new_api_user = _find_new_api_user(local_storage, session_storage)
# 3. 单个 Session cookie(保留,供独立 fallback / bearer 降级使用)
for c in cookies:
cname = c["name"].lower()
@@ -168,6 +174,7 @@ def _curate_candidates(
extra = {"cookie_name": c["name"], "cookie_value": c["value"]}
if cname == "session" and new_api_user:
extra["new_api_user"] = new_api_user
extra["user_id"] = new_api_user
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
f"Cookie {c['name']} ({c['domain']})", confidence,
extra=extra)
@@ -182,6 +189,15 @@ def _curate_candidates(
return candidates
def _find_user_header(headers: list[dict[str, str]]) -> str:
for header in headers:
if header.get("type") == "user_id":
value = str(header.get("value") or "").strip()
if value:
return value
return ""
def _find_storage_value(*stores: dict[str, str], key: str) -> str:
for store in stores:
value = store.get(key)
@@ -80,6 +80,7 @@ def _normalize_headers(value: Any) -> list[dict[str, str]]:
"type": str(item.get("type") or "authorization"),
"value": header_value,
"url": str(item.get("url") or ""),
"header": str(item.get("header") or ""),
})
return result
+28 -5
View File
@@ -137,6 +137,8 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
if isinstance(value, list):
return _normalize(value)
if isinstance(value, dict) and not _is_success_response(value):
raise UpstreamError(_response_message(value, "upstream API returned success=false"))
if isinstance(value, dict):
for key in ("data", "items", "groups", "available_groups", "availableGroups"):
nested = value.get(key)
@@ -145,8 +147,11 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
elif isinstance(nested, dict):
# Handle /api/user/self/groups where data is a dict of group_name -> { desc, ratio }
out = []
for k in nested.keys():
out.append({"id": k, "name": k})
for k, item in nested.items():
row = {"id": k, "name": k}
if isinstance(item, dict):
row.update(item)
out.append(row)
return out
return None
@@ -316,9 +321,16 @@ class UpstreamClient:
bool(self.auth_config.get("new_api_user"))
or login_path == "/api/user/login"
or self.auth_type == "cookie"
or self.auth_type == "nox_token"
)
)
def _user_header_value(self) -> str:
return _clean_auth_header_value(
self.auth_config.get("user_id", "") or self.auth_config.get("new_api_user", ""),
"User header",
)
def _headers(self, auth: bool = True) -> dict[str, str]:
headers: dict[str, str] = {
"Accept": "application/json",
@@ -330,6 +342,13 @@ class UpstreamClient:
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token")
if token:
headers["Authorization"] = f"Bearer {token}"
elif self.auth_type == "nox_token":
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Nox access token")
user_id = self._user_header_value()
if token:
headers["Authorization"] = f"Bearer {token}"
if user_id:
headers["Nox-Api-User"] = user_id
elif self.auth_type == "api_key":
key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key")
header = self.auth_config.get("header", "Authorization")
@@ -339,20 +358,24 @@ class UpstreamClient:
cookie_str = _clean_auth_header_value(self.auth_config.get("cookie_string", ""), "Cookie")
if cookie_str:
headers["Cookie"] = cookie_str
new_api_user = _clean_auth_header_value(self.auth_config.get("new_api_user", ""), "New-Api-User")
if new_api_user:
headers["New-Api-User"] = new_api_user
user_id = self._user_header_value()
if user_id:
headers["New-Api-User"] = user_id
headers["Nox-Api-User"] = user_id
elif self.auth_type == "login_password" and self._token:
token = _clean_auth_header_value(self._token, "Login token")
if token:
headers["Authorization"] = f"Bearer {token}"
if self.auth_type == "login_password" and self._new_api_user:
headers["New-Api-User"] = self._new_api_user
headers["Nox-Api-User"] = self._new_api_user
return headers
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
if auth and self.auth_type == "cookie" and "user/self" in path and not self.auth_config.get("new_api_user"):
raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream")
if auth and self.auth_type == "nox_token" and "user/self" in path and not self._user_header_value():
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
url = self._url(path)
if body is not None:
resp = self._client.request(