Remove server remote browser support

This commit is contained in:
liumangmang
2026-06-02 19:25:20 +08:00
parent 3181a6f6cc
commit a42bcba483
22 changed files with 151 additions and 5029 deletions
+2 -109
View File
@@ -1,45 +1,23 @@
"""Auth capture API — remote browser for manual login + credential extraction."""
"""Auth capture API for real-browser credential imports."""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.auth_capture_service import extract_all
from app.services.browser_import_service import (
ImportSessionError,
browser_imports,
build_import_result,
)
from app.services.browser_session_service import (
BrowserDependencyError,
BrowserSessionError,
browser_sessions,
)
from app.utils.auth import get_current_user, get_user_from_token_param
logger = logging.getLogger(__name__)
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"})
class CaptureSessionCreate(BaseModel):
url: str = Field(..., description="Target login page URL to open in browser")
width: int = Field(default=1280, ge=320, le=2560)
height: int = Field(default=720, ge=240, le=1600)
class CaptureSessionResponse(BaseModel):
session_id: str
ws_url: str
class CaptureExtractResponse(BaseModel):
cookies: list[dict] = []
storage: dict[str, str] = {}
@@ -82,91 +60,6 @@ def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
}
def _browser_error(exc: Exception) -> HTTPException:
if isinstance(exc, BrowserDependencyError):
return HTTPException(503, str(exc))
if isinstance(exc, BrowserSessionError):
return HTTPException(409, str(exc))
if isinstance(exc, KeyError):
return HTTPException(404, "session not found")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc))
logger.exception("auth-capture error")
return HTTPException(500, "internal error")
def _ws_url(session_id: str, token: str) -> str:
"""Build WebSocket URL for the remote browser viewer."""
return f"/api/browser-sessions/{session_id}/ws?token={token}"
@router.post("/sessions", response_model=CaptureSessionResponse, status_code=201)
async def create_capture_session(
body: CaptureSessionCreate,
_=Depends(get_current_user),
):
"""Create a temporary browser session pointing at the given URL.
Returns a session_id and ws_url for the frontend to view/interact.
The user should manually log in, then call GET /extract.
"""
try:
session = await browser_sessions.create_ephemeral(
url=body.url,
width=body.width,
height=body.height,
)
except Exception as exc:
raise _browser_error(exc)
# Build a short-lived token for WS auth (reuse current user's token logic)
# The frontend already has the user's Bearer token, pass it via query param
return CaptureSessionResponse(
session_id=session.id,
ws_url=f"/api/browser-sessions/{session.id}/ws",
)
@router.get("/sessions/{session_id}/extract", response_model=CaptureExtractResponse)
async def extract_credentials(
session_id: str,
include_raw: bool = Query(default=False, description="Include full cookies/storage/headers in response"),
_=Depends(get_current_user),
):
"""Extract auth credentials from the browser session.
By default only returns curated candidates (typed, scored, with masked preview).
Pass include_raw=true to also get full cookies, localStorage, and headers.
"""
try:
session = browser_sessions.get_session(session_id)
except KeyError:
raise HTTPException(404, "session not found")
try:
result = await extract_all(session)
except Exception as exc:
raise _browser_error(exc)
if not include_raw:
# Strip raw data — only keep curated candidates with masked previews
candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])]
return CaptureExtractResponse(candidates=candidates)
return CaptureExtractResponse(**result)
@router.delete("/sessions/{session_id}", status_code=204)
async def close_capture_session(
session_id: str,
_=Depends(get_current_user),
):
"""Close and release the auth-capture browser session."""
try:
await browser_sessions.close(session_id)
except Exception as exc:
raise _browser_error(exc)
@router.post("/import-sessions", response_model=ImportSessionCreateResponse, status_code=201)
async def create_import_session(
body: ImportSessionCreate,
-417
View File
@@ -1,417 +0,0 @@
"""Remote browser session API."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.custom_page import CustomPage
from app.services.browser_session_service import (
BrowserDependencyError,
BrowserSessionError,
browser_sessions,
)
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
from app.database import SessionLocal
from app.models.admin_user import AdminUser
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser-sessions", tags=["browser-sessions"])
class BrowserSessionCreate(BaseModel):
custom_page_id: int
width: int = Field(default=1280)
height: int = Field(default=720)
class BrowserTabResponse(BaseModel):
id: str
title: str
url: str
created_at: float
class BrowserSessionResponse(BaseModel):
id: str
custom_page_id: int
url: str
title: str
active_tab_id: Optional[str] = None
tabs: Optional[list[BrowserTabResponse]] = None
tab_revision: Optional[int] = 0
class BrowserSelectionResponse(BaseModel):
text: str
class BrowserEvent(BaseModel):
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
x: Optional[float] = None
y: Optional[float] = None
button: Optional[Literal["left", "right", "middle"]] = "left"
text: Optional[str] = None
key: Optional[str] = None
delta_x: Optional[float] = 0
delta_y: Optional[float] = 0
width: Optional[int] = None
height: Optional[int] = None
def _error_from_browser(exc: Exception) -> HTTPException:
if isinstance(exc, BrowserDependencyError):
return HTTPException(503, str(exc))
if isinstance(exc, BrowserSessionError):
return HTTPException(409, str(exc))
if isinstance(exc, KeyError):
return HTTPException(404, "browser session not found")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc))
return HTTPException(502, f"Browser error: {exc}")
@router.post("", response_model=BrowserSessionResponse, status_code=201)
async def create_session(
body: BrowserSessionCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
page = db.query(CustomPage).filter(CustomPage.id == body.custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not configured for remote browser mode")
login_config = {
"enabled": page.login_autofill_enabled,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
try:
session = await browser_sessions.create(page.id, page.url, body.width, body.height, login_config)
return await browser_sessions.state(session.id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}", response_model=BrowserSessionResponse)
async def get_session(session_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/screenshot")
async def session_screenshot(session_id: str, _=Depends(get_user_from_token_param)):
try:
image = await browser_sessions.screenshot(session_id)
except Exception as exc:
raise _error_from_browser(exc)
return Response(content=image, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
@router.post("/{session_id}/events", response_model=BrowserSessionResponse)
async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_user)):
try:
payload: dict[str, Any] = body.model_dump(exclude_none=True)
event_type = payload.pop("type")
return await browser_sessions.event(session_id, event_type, payload)
except Exception as exc:
raise _error_from_browser(exc)
@router.post("/{session_id}/tabs/{tab_id}/activate", response_model=BrowserSessionResponse)
async def activate_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.activate_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}/tabs/{tab_id}", response_model=BrowserSessionResponse)
async def close_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.close_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)):
try:
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
except Exception as exc:
raise _error_from_browser(exc)
class BrowserClipboardResponse(BaseModel):
text: Optional[str] = None
error: Optional[str] = None
@router.get("/{session_id}/clipboard", response_model=BrowserClipboardResponse)
async def session_clipboard(session_id: str, _=Depends(get_current_user)):
"""Read text from the remote browser's clipboard."""
from fastapi.responses import JSONResponse
try:
text, error = await browser_sessions.read_clipboard(session_id)
body: dict[str, Any] = {}
if text:
body["text"] = text
elif error == "denied":
body["error"] = "远程浏览器未授予剪贴板读取权限"
elif error == "read_failed":
body["error"] = "读取远程剪贴板时发生内部错误"
else:
if error:
logger.warning("clipboard read error for %s: %s", session_id[:12], error)
body["error"] = "远程剪贴板为空"
return JSONResponse(content=body, headers={"Cache-Control": "no-store"})
except Exception as exc:
raise _error_from_browser(exc)
class AutofillLoginResponse(BaseModel):
success: bool
message: str
@router.post("/{session_id}/autofill-login", response_model=AutofillLoginResponse)
async def autofill_login(session_id: str, _=Depends(get_current_user)):
"""Manually trigger login autofill for the remote browser session.
Uses the linked custom page's saved credentials. Never returns passwords.
"""
try:
session_state = await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
from app.database import SessionLocal as _Db
from app.models.custom_page import CustomPage
db = _Db()
try:
page = db.query(CustomPage).filter(
CustomPage.id == session_state["custom_page_id"]
).first()
if not page or not page.enabled:
raise HTTPException(400, "linked custom page is not available")
if page.access_mode != "remote_browser":
raise HTTPException(400, "linked custom page is not in remote browser mode")
if not page.login_autofill_enabled:
return AutofillLoginResponse(success=False, message="该页面未启用自动填充登录")
if not page.login_username or not page.login_password:
return AutofillLoginResponse(success=False, message="该页面未保存账号密码")
login_config = {
"enabled": True,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
filled = await browser_sessions.autofill_login(session_id, login_config)
if filled:
return AutofillLoginResponse(success=True, message="已填入账号密码")
return AutofillLoginResponse(
success=False,
message="未找到登录输入框,请先关闭弹窗或进入登录页后重试",
)
finally:
db.close()
@router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id)
@router.delete("/profiles/{custom_page_id}", status_code=204)
async def clear_profile(custom_page_id: int, _=Depends(get_current_user)):
"""Close active session for the page and delete its profile directory.
On next open the browser starts fresh, losing login state.
"""
from app.models.custom_page import CustomPage
from app.database import SessionLocal as _Db
db = _Db()
try:
page = db.query(CustomPage).filter(CustomPage.id == custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "custom page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not in remote browser mode")
try:
await browser_sessions.clear_profile(custom_page_id, page.url)
except RuntimeError as exc:
raise HTTPException(500, str(exc))
finally:
db.close()
# ——— WebSocket stream ———
# Frame interval & diff detection (tuned for CPU efficiency)
_WS_MIN_INTERVAL = 0.15
_WS_IDLE_INTERVAL = 1.00
_WS_ACTIVE_INTERVAL = 0.20
_WS_BACKOFF_INTERVAL = 2.00
_WS_DEEP_IDLE_INTERVAL = 5.00
_WS_ACTIVE_WINDOW = 1.25
async def _ws_authenticate(token: Optional[str]) -> bool:
"""Validate JWT token for WebSocket connections."""
if not token:
return False
email = decode_token(token)
if not email:
return False
db = SessionLocal()
try:
user = db.query(AdminUser).filter(AdminUser.email == email).first()
return user is not None
finally:
db.close()
@router.websocket("/{session_id}/ws")
async def session_ws(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(default=None),
):
"""WebSocket endpoint: pushes JPEG frames as binary, receives JSON event messages."""
# Authenticate before accepting
if not await _ws_authenticate(token):
await websocket.close(code=4401)
return
await websocket.accept()
# Track when a user event arrived so we can temporarily speed up
last_event_at: float = 0.0
last_frame_hash: str = ""
unchanged_count = 0
# Task: receive events from client
async def receive_loop():
nonlocal last_event_at, unchanged_count
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if not msg_type:
continue
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
try:
await browser_sessions.event(session_id, msg_type, payload, include_state=False)
last_event_at = asyncio.get_event_loop().time()
unchanged_count = 0
except Exception as exc:
logger.warning("ws event error: %s", exc)
try:
await websocket.send_json({"error": str(exc)})
except Exception:
pass
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws receive_loop ended: %s", exc)
# Task: push screenshots
async def push_loop():
nonlocal last_frame_hash, unchanged_count
last_tab_revision = -1
try:
while True:
now = asyncio.get_event_loop().time()
if (now - last_event_at) < _WS_ACTIVE_WINDOW:
interval = _WS_ACTIVE_INTERVAL
elif unchanged_count >= 9:
interval = _WS_DEEP_IDLE_INTERVAL
elif unchanged_count >= 3:
interval = _WS_BACKOFF_INTERVAL
else:
interval = _WS_IDLE_INTERVAL
try:
# Check for tab state changes
session_obj = browser_sessions.get_session(session_id)
if session_obj.tab_revision != last_tab_revision:
last_tab_revision = session_obj.tab_revision
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "state", "session": state})
frame = await asyncio.wait_for(
browser_sessions.screenshot(session_id), timeout=5.0)
except asyncio.TimeoutError:
logger.warning("ws screenshot timeout for %s", session_id[:12])
await asyncio.sleep(interval)
continue
except KeyError:
await websocket.send_json({"error": "session_not_found"})
break
except Exception as exc:
logger.warning("ws screenshot error: %s", exc)
await asyncio.sleep(interval)
continue
frame_hash = hashlib.md5(frame).hexdigest()
if frame_hash != last_frame_hash:
last_frame_hash = frame_hash
unchanged_count = 0
try:
await websocket.send_bytes(frame)
except Exception:
break
else:
unchanged_count += 1
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws push_loop ended: %s", exc)
# Send initial metadata so client knows session info
try:
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "init", "session": state})
except Exception as exc:
await websocket.send_json({"error": f"session error: {exc}"})
await websocket.close()
return
recv_task = asyncio.create_task(receive_loop())
push_task = asyncio.create_task(push_loop())
# Run until one side closes
done, pending = await asyncio.wait(
[recv_task, push_task],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
+5 -168
View File
@@ -1,7 +1,6 @@
"""Custom pages CRUD router + authenticated iframe proxy."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any, List, Literal, Optional
@@ -18,12 +17,8 @@ from app.models.admin_user import AdminUser
from app.models.custom_page import CustomPage
from app.models.upstream import Upstream
from app.services.upstream_client import _find_user_id
from app.services.auth_capture_service import extract_all
from app.services.browser_session_service import browser_sessions
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
# Headers that prevent iframe embedding — strip them from proxied responses
@@ -45,7 +40,7 @@ class CustomPageCreate(BaseModel):
sort_order: int = 0
enabled: bool = True
use_proxy: bool = False
access_mode: Literal["direct", "proxy", "remote_browser"] = "direct"
access_mode: Literal["direct", "proxy"] = "direct"
description: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
@@ -63,7 +58,7 @@ class CustomPageUpdate(BaseModel):
sort_order: Optional[int] = None
enabled: Optional[bool] = None
use_proxy: Optional[bool] = None
access_mode: Optional[Literal["direct", "proxy", "remote_browser"]] = None
access_mode: Optional[Literal["direct", "proxy"]] = None
description: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
@@ -118,7 +113,7 @@ def _page_response(page: CustomPage) -> CustomPageResponse:
sort_order=page.sort_order,
enabled=page.enabled,
use_proxy=page.use_proxy,
access_mode=page.access_mode,
access_mode="proxy" if page.use_proxy or page.access_mode == "proxy" else "direct",
description=page.description,
login_username=page.login_username,
login_username_selector=page.login_username_selector,
@@ -143,6 +138,8 @@ def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)):
@router.post("", response_model=CustomPageResponse, status_code=201)
def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)):
data = body.model_dump()
if "access_mode" not in body.model_fields_set and data.get("use_proxy"):
data["access_mode"] = "proxy"
data["use_proxy"] = data["access_mode"] == "proxy"
for key in (
"login_username",
@@ -210,166 +207,6 @@ def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_u
db.commit()
# ---- One-click refresh auth ----
import json as _json
class RefreshAuthResponse(BaseModel):
success: bool
message: str
warning: Optional[str] = None
def _norm_path(value: Any) -> str:
return str(value or "").strip().rstrip("/")
def _detect_upstream_platform(upstream: Upstream, auth_config: dict) -> str:
api_prefix = _norm_path(upstream.api_prefix)
groups_endpoint = _norm_path(upstream.groups_endpoint)
rate_endpoint = _norm_path(upstream.rate_endpoint)
login_path = _norm_path(auth_config.get("login_path"))
if groups_endpoint == "/api/user/self/groups" or login_path == "/api/user/login":
return "new-api-user"
if api_prefix == "/api/v1" or groups_endpoint in {"/groups/available", "/groups/rates"} or login_path == "/auth/login":
return "sub2api"
return "unknown"
def _first_candidate(candidates: list[dict], *types: str) -> Optional[dict]:
for c in candidates:
if c.get("type") in types:
return c
return None
def _pick_best_candidate(candidates: list[dict], preferred_auth_type: str, platform: str = "unknown") -> Optional[dict]:
if not candidates:
return None
if platform == "sub2api":
return _first_candidate(candidates, "bearer_token", "api_key")
if platform == "new-api-user":
return _first_candidate(candidates, "cookie_bundle", "cookie", "bearer_token", "api_key")
if preferred_auth_type == "cookie":
return _first_candidate(candidates, "cookie_bundle", "cookie")
elif preferred_auth_type in ("bearer", "api_key"):
type_map = {"bearer": "bearer_token", "api_key": "api_key"}
preferred = type_map.get(preferred_auth_type)
if preferred:
return _first_candidate(candidates, preferred)
# fallback:排序后取第一个
return candidates[0]
@router.post("/{pid}/refresh-auth", response_model=RefreshAuthResponse)
async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
if not page:
raise HTTPException(404, "page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "page is not in remote_browser mode")
if not page.linked_upstream_id:
raise HTTPException(400, "page has no linked upstream")
upstream = db.query(Upstream).filter(Upstream.id == page.linked_upstream_id).first()
if not upstream:
raise HTTPException(404, "linked upstream not found")
try:
session = browser_sessions.find_by_page_id(page.id)
except KeyError:
return RefreshAuthResponse(success=False, message="请先打开远程浏览器并登录")
try:
result = await extract_all(session)
except Exception as exc:
return RefreshAuthResponse(success=False, message=f"提取失败: {exc}")
candidates = result.get("candidates", [])
existing_config = _json.loads(upstream.auth_config_json or "{}")
platform = _detect_upstream_platform(upstream, existing_config)
candidate = _pick_best_candidate(candidates, upstream.auth_type, platform)
if not candidate:
if platform == "sub2api" and _first_candidate(candidates, "cookie_bundle", "cookie"):
return RefreshAuthResponse(
success=False,
message="Sub2API 需要 Bearer Token;当前只提取到 Cookie。请在远程浏览器完成登录后刷新页面或触发一次接口请求,再重新提取。",
)
return RefreshAuthResponse(success=False, message="未提取到有效凭证,请确认已在远程浏览器中登录")
ctype = candidate["type"]
if ctype in ("cookie_bundle", "cookie"):
upstream.auth_type = "cookie"
# cookie_bundle.value 已是完整 cookie_stringcookie.value 是 "name=value" 格式
existing_config["cookie_string"] = candidate.get("value", "")
if candidate.get("new_api_user"):
existing_config["new_api_user"] = candidate["new_api_user"]
if platform == "new-api-user":
upstream.api_prefix = ""
upstream.groups_endpoint = "/api/user/self/groups"
upstream.rate_endpoint = "/api/user/self/groups"
elif ctype == "bearer_token":
upstream.auth_type = "bearer"
raw = candidate.get("value", "")
# Clean up: strip whitespace, remove "Bearer " prefix if present
token = raw.strip()
if token.startswith("Bearer "):
token = token[7:].strip()
# Validate token can be used as HTTP header value
try:
token.encode("latin-1")
except UnicodeEncodeError:
return RefreshAuthResponse(
success=False,
message="提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
)
existing_config["token"] = token
elif ctype == "api_key":
upstream.auth_type = "api_key"
existing_config["key"] = candidate.get("value", "")
existing_config.setdefault("header", "X-API-Key")
upstream.auth_config_json = _json.dumps(existing_config, ensure_ascii=False)
upstream.updated_at = datetime.now(timezone.utc)
# ── 宽松验证:写回后尝试调用 get_available_groups 验证凭证可用性 ──
# 失败时仍然 commit(凭证已写入),但在 message 里说明验证失败
# 这样用户仍能看到新凭证已写入,便于 debugcf_clearance 绑 IP 时验证必然失败)
warning_msg: Optional[str] = None
try:
from app.services.upstream_client import UpstreamClient
groups_endpoint = upstream.groups_endpoint or "/groups/available"
new_auth_config = _json.loads(upstream.auth_config_json)
with UpstreamClient(
base_url=upstream.base_url,
api_prefix=upstream.api_prefix or "",
auth_type=upstream.auth_type,
auth_config=new_auth_config,
timeout=float(upstream.timeout_seconds or 30),
) as uc:
uc.get_available_groups(groups_endpoint)
logger.info("refresh_auth: upstream %s credential verification passed", upstream.id)
except Exception as exc:
warning_msg = (
f"凭证已写入但 API 验证失败:{exc}"
"若 SmartUp 与远程浏览器不在同一 IPcf_clearance 可能无法复用,请手动测试连接。"
)
logger.warning(
"refresh_auth: upstream %s credential verification failed (written anyway): %s",
upstream.id, exc,
)
db.commit()
auth_type_label = upstream.auth_type
cookie_count = candidate.get("cookie_count", "")
count_str = f"{cookie_count} 个 cookie" if cookie_count else ""
success_msg = f"凭证已刷新 ({auth_type_label}{count_str})"
return RefreshAuthResponse(success=True, message=success_msg, warning=warning_msg)
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
_STRIP_RESP = {