From 08fe06d0749721e4c0efcc51ca07abf431336eed Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 10:57:06 +0800 Subject: [PATCH] Fix control unauthorized: map strategy 401 to 502 and keep fleet headers on redirect. Co-authored-by: Cursor --- backend/app/api/fleet.py | 5 ++++ control/backend/app/api/nodes.py | 41 ++++++++++++++++---------- control/backend/app/proxy.py | 11 ++++++- control/frontend/src/api.ts | 15 +++++++--- control/frontend/src/pages/Monitor.tsx | 9 +++++- 5 files changed, 59 insertions(+), 22 deletions(-) diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index c22a025..27bdb44 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -61,6 +61,7 @@ def clear_fleet_token(db=None) -> None: def require_fleet_token( x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None, + authorization: Annotated[str | None, Header()] = None, ) -> str: db = get_db() stored = (db.get_setting(_SETTING_HASH, "") or "").strip() @@ -70,6 +71,10 @@ def require_fleet_token( detail="策略机未配置中控 API Token", ) provided = (x_fleet_token or "").strip() + if not provided and authorization: + auth = authorization.strip() + if auth.lower().startswith("fleet "): + provided = auth[6:].strip() if not provided or not hmac.compare_digest(stored, _hash_token(provided)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index 50b6401..75208e0 100644 --- a/control/backend/app/api/nodes.py +++ b/control/backend/app/api/nodes.py @@ -33,6 +33,20 @@ def _http_detail(data: Any) -> str: return str(data) +def _raise_node_error(code: int, data: Any) -> None: + """策略机错误不得用 401 回传,否则中控前端会误清登录态。""" + detail = _http_detail(data) + if code in (401, 403): + raise HTTPException( + status_code=502, + detail=f"策略机鉴权失败({detail})。请在中控重新生成 Token,并到策略机「系统设置→登录账户」保存同一 Token。", + ) + raise HTTPException( + status_code=code if 400 <= code < 600 else 502, + detail=detail, + ) + + class NodeCreate(BaseModel): name: str = Field(min_length=1, max_length=64) base_url: str = Field(min_length=8, max_length=256) @@ -74,6 +88,13 @@ async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> di code, data = await call_node(node, "GET", "/api/fleet/status") if code == 200 and isinstance(data, dict): item["fleet"] = data + item["fleet_ok"] = True + else: + item["fleet_ok"] = False + item["fleet_error"] = _http_detail(data) if data else f"HTTP {code}" + elif not node.get("token_sealed"): + item["fleet_ok"] = False + item["fleet_error"] = "未生成 Token" items.append(item) return {"nodes": items} @@ -178,10 +199,7 @@ async def node_start( raise HTTPException(status_code=404, detail="节点不存在") code, data = await call_node(node, "POST", "/api/fleet/start") if code >= 400: - raise HTTPException( - status_code=code if 400 <= code < 600 else 502, - detail=_http_detail(data), - ) + _raise_node_error(code, data) return {"ok": True, "result": data} @@ -196,10 +214,7 @@ async def node_pause( raise HTTPException(status_code=404, detail="节点不存在") code, data = await call_node(node, "POST", "/api/fleet/pause") if code >= 400: - raise HTTPException( - status_code=code if 400 <= code < 600 else 502, - detail=_http_detail(data), - ) + _raise_node_error(code, data) return {"ok": True, "result": data} @@ -214,10 +229,7 @@ async def node_update( raise HTTPException(status_code=404, detail="节点不存在") code, data = await call_node(node, "POST", "/api/fleet/update") if code >= 400: - raise HTTPException( - status_code=code if 400 <= code < 600 else 502, - detail=_http_detail(data), - ) + _raise_node_error(code, data) return {"ok": True, "result": data} @@ -233,10 +245,7 @@ async def node_login_url( raise HTTPException(status_code=404, detail="节点不存在") code, data = await call_node(node, "POST", "/api/fleet/issue-login") if code >= 400: - raise HTTPException( - status_code=code if 400 <= code < 600 else 502, - detail=_http_detail(data), - ) + _raise_node_error(code, data) path = "" if isinstance(data, dict): path = str(data.get("login_path") or "") diff --git a/control/backend/app/proxy.py b/control/backend/app/proxy.py index b0eeec1..92447b6 100644 --- a/control/backend/app/proxy.py +++ b/control/backend/app/proxy.py @@ -41,11 +41,20 @@ async def call_node( if not tok: return 400, {"detail": "该机尚未生成/保存中控 Token"} headers["X-Fleet-Token"] = tok + headers["Authorization"] = f"Fleet {tok}" url = f"{base}{path}" timeout = settings.control_http_timeout_sec try: - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: res = await client.request(method, url, headers=headers, json=json_body) + # 少数反代会 301/302 补尾斜杠;手动跟一次并保留 Token 头 + if res.status_code in (301, 302, 307, 308) and res.headers.get("location"): + loc = res.headers["location"] + if loc.startswith("/"): + from urllib.parse import urljoin + + loc = urljoin(url, loc) + res = await client.request(method, loc, headers=headers, json=json_body) try: data = res.json() except Exception: diff --git a/control/frontend/src/api.ts b/control/frontend/src/api.ts index dd84012..9314ab5 100644 --- a/control/frontend/src/api.ts +++ b/control/frontend/src/api.ts @@ -30,10 +30,6 @@ export async function apiFetch( const token = getToken(); if (token) headers.set("Authorization", `Bearer ${token}`); const res = await fetch(path, { ...options, headers }); - if (res.status === 401) { - clearSession(); - throw new Error("unauthorized"); - } const text = await res.text(); let data: unknown = null; try { @@ -41,6 +37,15 @@ export async function apiFetch( } catch { data = { detail: text }; } + if (res.status === 401) { + // 仅中控自身鉴权失败才清会话;勿把策略机错误当成掉登录 + clearSession(); + const detail = + typeof data === "object" && data && "detail" in data + ? String((data as { detail: unknown }).detail) + : "unauthorized"; + throw new Error(detail || "unauthorized"); + } if (!res.ok) { const detail = typeof data === "object" && data && "detail" in data @@ -71,5 +76,7 @@ export type NodeCard = { online?: boolean; health?: Record | null; fleet?: Record | null; + fleet_ok?: boolean; + fleet_error?: string | null; error?: string | null; }; diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index fa9e9f2..acbccb0 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -160,9 +160,16 @@ export default function MonitorPage() {
Token
-
{n.token_configured ? "已配对" : "未配对"}
+
+ {n.token_configured + ? n.fleet_ok === false + ? "配对失败" + : "已配对" + : "未配对"} +
+ {n.fleet_error ?
{n.fleet_error}
: null} {n.error ?
{n.error}
: null}