Fix control unauthorized: map strategy 401 to 502 and keep fleet headers on redirect.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -61,6 +61,7 @@ def clear_fleet_token(db=None) -> None:
|
|||||||
|
|
||||||
def require_fleet_token(
|
def require_fleet_token(
|
||||||
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
|
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
||||||
@@ -70,6 +71,10 @@ def require_fleet_token(
|
|||||||
detail="策略机未配置中控 API Token",
|
detail="策略机未配置中控 API Token",
|
||||||
)
|
)
|
||||||
provided = (x_fleet_token or "").strip()
|
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)):
|
if not provided or not hmac.compare_digest(stored, _hash_token(provided)):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ def _http_detail(data: Any) -> str:
|
|||||||
return str(data)
|
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):
|
class NodeCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=64)
|
name: str = Field(min_length=1, max_length=64)
|
||||||
base_url: str = Field(min_length=8, max_length=256)
|
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")
|
code, data = await call_node(node, "GET", "/api/fleet/status")
|
||||||
if code == 200 and isinstance(data, dict):
|
if code == 200 and isinstance(data, dict):
|
||||||
item["fleet"] = data
|
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)
|
items.append(item)
|
||||||
return {"nodes": items}
|
return {"nodes": items}
|
||||||
|
|
||||||
@@ -178,10 +199,7 @@ async def node_start(
|
|||||||
raise HTTPException(status_code=404, detail="节点不存在")
|
raise HTTPException(status_code=404, detail="节点不存在")
|
||||||
code, data = await call_node(node, "POST", "/api/fleet/start")
|
code, data = await call_node(node, "POST", "/api/fleet/start")
|
||||||
if code >= 400:
|
if code >= 400:
|
||||||
raise HTTPException(
|
_raise_node_error(code, data)
|
||||||
status_code=code if 400 <= code < 600 else 502,
|
|
||||||
detail=_http_detail(data),
|
|
||||||
)
|
|
||||||
return {"ok": True, "result": data}
|
return {"ok": True, "result": data}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,10 +214,7 @@ async def node_pause(
|
|||||||
raise HTTPException(status_code=404, detail="节点不存在")
|
raise HTTPException(status_code=404, detail="节点不存在")
|
||||||
code, data = await call_node(node, "POST", "/api/fleet/pause")
|
code, data = await call_node(node, "POST", "/api/fleet/pause")
|
||||||
if code >= 400:
|
if code >= 400:
|
||||||
raise HTTPException(
|
_raise_node_error(code, data)
|
||||||
status_code=code if 400 <= code < 600 else 502,
|
|
||||||
detail=_http_detail(data),
|
|
||||||
)
|
|
||||||
return {"ok": True, "result": data}
|
return {"ok": True, "result": data}
|
||||||
|
|
||||||
|
|
||||||
@@ -214,10 +229,7 @@ async def node_update(
|
|||||||
raise HTTPException(status_code=404, detail="节点不存在")
|
raise HTTPException(status_code=404, detail="节点不存在")
|
||||||
code, data = await call_node(node, "POST", "/api/fleet/update")
|
code, data = await call_node(node, "POST", "/api/fleet/update")
|
||||||
if code >= 400:
|
if code >= 400:
|
||||||
raise HTTPException(
|
_raise_node_error(code, data)
|
||||||
status_code=code if 400 <= code < 600 else 502,
|
|
||||||
detail=_http_detail(data),
|
|
||||||
)
|
|
||||||
return {"ok": True, "result": data}
|
return {"ok": True, "result": data}
|
||||||
|
|
||||||
|
|
||||||
@@ -233,10 +245,7 @@ async def node_login_url(
|
|||||||
raise HTTPException(status_code=404, detail="节点不存在")
|
raise HTTPException(status_code=404, detail="节点不存在")
|
||||||
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
|
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
|
||||||
if code >= 400:
|
if code >= 400:
|
||||||
raise HTTPException(
|
_raise_node_error(code, data)
|
||||||
status_code=code if 400 <= code < 600 else 502,
|
|
||||||
detail=_http_detail(data),
|
|
||||||
)
|
|
||||||
path = ""
|
path = ""
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
path = str(data.get("login_path") or "")
|
path = str(data.get("login_path") or "")
|
||||||
|
|||||||
@@ -41,11 +41,20 @@ async def call_node(
|
|||||||
if not tok:
|
if not tok:
|
||||||
return 400, {"detail": "该机尚未生成/保存中控 Token"}
|
return 400, {"detail": "该机尚未生成/保存中控 Token"}
|
||||||
headers["X-Fleet-Token"] = tok
|
headers["X-Fleet-Token"] = tok
|
||||||
|
headers["Authorization"] = f"Fleet {tok}"
|
||||||
url = f"{base}{path}"
|
url = f"{base}{path}"
|
||||||
timeout = settings.control_http_timeout_sec
|
timeout = settings.control_http_timeout_sec
|
||||||
try:
|
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)
|
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:
|
try:
|
||||||
data = res.json()
|
data = res.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ export async function apiFetch<T>(
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
const res = await fetch(path, { ...options, headers });
|
const res = await fetch(path, { ...options, headers });
|
||||||
if (res.status === 401) {
|
|
||||||
clearSession();
|
|
||||||
throw new Error("unauthorized");
|
|
||||||
}
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data: unknown = null;
|
let data: unknown = null;
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +37,15 @@ export async function apiFetch<T>(
|
|||||||
} catch {
|
} catch {
|
||||||
data = { detail: text };
|
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) {
|
if (!res.ok) {
|
||||||
const detail =
|
const detail =
|
||||||
typeof data === "object" && data && "detail" in data
|
typeof data === "object" && data && "detail" in data
|
||||||
@@ -71,5 +76,7 @@ export type NodeCard = {
|
|||||||
online?: boolean;
|
online?: boolean;
|
||||||
health?: Record<string, unknown> | null;
|
health?: Record<string, unknown> | null;
|
||||||
fleet?: Record<string, unknown> | null;
|
fleet?: Record<string, unknown> | null;
|
||||||
|
fleet_ok?: boolean;
|
||||||
|
fleet_error?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -160,9 +160,16 @@ export default function MonitorPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Token</dt>
|
<dt>Token</dt>
|
||||||
<dd>{n.token_configured ? "已配对" : "未配对"}</dd>
|
<dd>
|
||||||
|
{n.token_configured
|
||||||
|
? n.fleet_ok === false
|
||||||
|
? "配对失败"
|
||||||
|
: "已配对"
|
||||||
|
: "未配对"}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{n.fleet_error ? <div className="err soft">{n.fleet_error}</div> : null}
|
||||||
{n.error ? <div className="err soft">{n.error}</div> : null}
|
{n.error ? <div className="err soft">{n.error}</div> : null}
|
||||||
<div className="node-actions">
|
<div className="node-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user