From e833b923c83e412d06d00c7a659191287f7e4b64 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Fri, 12 Jun 2026 06:49:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E8=BF=9C=E7=A8=8B=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=20Worker=20=E6=A1=A5=E6=8E=A5=E4=B8=8E?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E5=9B=BA=E5=AE=9A=E6=82=AC=E6=B5=AE=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下); 含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。 Co-authored-by: Cursor --- browser-worker/Dockerfile | 19 + browser-worker/browser_stealth.py | 46 +++ browser-worker/browser_url_safe.py | 77 ++++ browser-worker/config.py | 22 + browser-worker/docker-compose.yml | 11 + browser-worker/docker-entrypoint.sh | 11 + browser-worker/main.py | 147 +++++++ browser-worker/requirements.txt | 4 + browser-worker/session_manager.py | 346 ++++++++++++++++ .../audit/2026-06-12-remote-browser-worker.md | 126 ++++++ .../2026-06-11-global-floating-browser.md | 4 + .../2026-06-12-browser-captcha-stealth.md | 48 +++ .../2026-06-12-browser-p0-p1-fix-deploy.md | 47 +++ .../2026-06-12-browser-top-appbar.md | 19 + ...2026-06-12-remote-browser-worker-deploy.md | 69 ++++ ...12-settings-push-complete-sound-default.md | 33 ++ .../plans/2026-06-11-browser-nav-bookmarks.md | 43 ++ ...06-11-browser-worker-server-procurement.md | 253 ++++++++++++ .../2026-06-11-server-browser-chromium.md | 102 +++++ ...2026-06-11-browser-nav-bookmarks-design.md | 112 +++++ .../2026-06-11-embedded-browser-design.md | 10 +- ...26-06-11-server-browser-chromium-design.md | 173 ++++++++ docs/project/AI-HANDOFF-2026-06-03.md | 29 ++ .../2026-06-12-remote-browser-bug-patrol.md | 107 +++++ .../2026-06-12-remote-browser-patrol.md | 90 +++++ ...6-12-remote-browser-worker-verification.md | 30 ++ frontend/src/App.vue | 31 +- .../src/components/GlobalBrowserPanel.vue | 328 +++++++++++++++ frontend/src/composables/useGlobalBrowser.ts | 382 ++++++++++++++++++ .../composables/useRemoteBrowserSession.ts | 257 ++++++++++++ frontend/src/pages/BrowserPage.vue | 20 + frontend/src/pages/ServersPage.vue | 4 +- frontend/src/router/index.ts | 1 + server/api/browser.py | 87 ++++ server/api/browser_session.py | 197 +++++++++ server/api/settings.py | 12 +- server/config.py | 7 + server/infrastructure/browser/__init__.py | 1 + .../browser/session_registry.py | 51 +++ .../infrastructure/browser/worker_client.py | 82 ++++ server/main.py | 7 + server/utils/browser_ui_state.py | 161 ++++++++ server/utils/browser_url_safe.py | 79 ++++ tests/test_browser_ui_state.py | 7 +- tests/test_browser_url_safe.py | 30 ++ tests/test_settings_sound_defaults.py | 32 ++ 46 files changed, 3737 insertions(+), 17 deletions(-) create mode 100644 browser-worker/Dockerfile create mode 100644 browser-worker/browser_stealth.py create mode 100644 browser-worker/browser_url_safe.py create mode 100644 browser-worker/config.py create mode 100644 browser-worker/docker-compose.yml create mode 100644 browser-worker/docker-entrypoint.sh create mode 100644 browser-worker/main.py create mode 100644 browser-worker/requirements.txt create mode 100644 browser-worker/session_manager.py create mode 100644 docs/audit/2026-06-12-remote-browser-worker.md create mode 100644 docs/changelog/2026-06-12-browser-captcha-stealth.md create mode 100644 docs/changelog/2026-06-12-browser-p0-p1-fix-deploy.md create mode 100644 docs/changelog/2026-06-12-browser-top-appbar.md create mode 100644 docs/changelog/2026-06-12-remote-browser-worker-deploy.md create mode 100644 docs/changelog/2026-06-12-settings-push-complete-sound-default.md create mode 100644 docs/design/plans/2026-06-11-browser-nav-bookmarks.md create mode 100644 docs/design/plans/2026-06-11-browser-worker-server-procurement.md create mode 100644 docs/design/plans/2026-06-11-server-browser-chromium.md create mode 100644 docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md create mode 100644 docs/design/specs/2026-06-11-server-browser-chromium-design.md create mode 100644 docs/reports/2026-06-12-remote-browser-bug-patrol.md create mode 100644 docs/reports/2026-06-12-remote-browser-patrol.md create mode 100644 docs/reports/2026-06-12-remote-browser-worker-verification.md create mode 100644 frontend/src/components/GlobalBrowserPanel.vue create mode 100644 frontend/src/composables/useGlobalBrowser.ts create mode 100644 frontend/src/composables/useRemoteBrowserSession.ts create mode 100644 frontend/src/pages/BrowserPage.vue create mode 100644 server/api/browser.py create mode 100644 server/api/browser_session.py create mode 100644 server/infrastructure/browser/__init__.py create mode 100644 server/infrastructure/browser/session_registry.py create mode 100644 server/infrastructure/browser/worker_client.py create mode 100644 server/utils/browser_ui_state.py create mode 100644 server/utils/browser_url_safe.py create mode 100644 tests/test_browser_url_safe.py create mode 100644 tests/test_settings_sound_defaults.py diff --git a/browser-worker/Dockerfile b/browser-worker/Dockerfile new file mode 100644 index 00000000..d9f578e2 --- /dev/null +++ b/browser-worker/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +RUN apt-get update && apt-get install -y --no-install-recommends xvfb \ + && rm -rf /var/lib/apt/lists/* + +COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./ +RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh + +ENV WORKER_BIND=0.0.0.0:8443 +ENV DISPLAY=:99 + +EXPOSE 8443 + +ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"] diff --git a/browser-worker/browser_stealth.py b/browser-worker/browser_stealth.py new file mode 100644 index 00000000..0e6130ab --- /dev/null +++ b/browser-worker/browser_stealth.py @@ -0,0 +1,46 @@ +"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云).""" + +from __future__ import annotations + +CHROMIUM_ARGS = [ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-infobars", + "--window-position=0,0", + "--lang=zh-CN", +] + +# Playwright adds --enable-automation by default; drop it for CAPTCHA sites. +IGNORE_DEFAULT_ARGS = ["--enable-automation"] + +STEALTH_INIT_SCRIPT = """ +(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }); + Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] }); + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + ); + window.chrome = window.chrome || { runtime: {} }; +})(); +""" + +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + +CONTEXT_OPTIONS = { + "user_agent": DEFAULT_USER_AGENT, + "locale": "zh-CN", + "timezone_id": "Asia/Shanghai", + "color_scheme": "light", + "device_scale_factor": 1, + "has_touch": False, + "is_mobile": False, + "java_script_enabled": True, +} diff --git a/browser-worker/browser_url_safe.py b/browser-worker/browser_url_safe.py new file mode 100644 index 00000000..fc337c0a --- /dev/null +++ b/browser-worker/browser_url_safe.py @@ -0,0 +1,77 @@ +"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py).""" + +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlparse + +BLOCKED_HOSTNAMES = frozenset({ + "localhost", + "localhost.localdomain", + "metadata", + "metadata.google.internal", + "169.254.169.254", +}) + + +def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: + return True + if isinstance(ip, ipaddress.IPv4Address): + if ip in ipaddress.ip_network("0.0.0.0/8"): + return True + if ip in ipaddress.ip_network("100.64.0.0/10"): + return True + if isinstance(ip, ipaddress.IPv6Address): + if ip == ipaddress.IPv6Address("::1"): + return True + if ip in ipaddress.ip_network("fc00::/7"): + return True + if ip in ipaddress.ip_network("fe80::/10"): + return True + return False + + +def resolve_host_blocked(hostname: str) -> str | None: + host = hostname.lower().strip(".") + if not host: + return "Empty hostname" + if host in BLOCKED_HOSTNAMES or "metadata" in host: + return f"Blocked hostname: {hostname}" + try: + infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + return f"DNS resolution failed: {exc}" + if not infos: + return f"No addresses for hostname: {hostname}" + for info in infos: + ip_str = info[4][0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return f"Invalid IP: {ip_str}" + if _blocked_ip(ip): + return f"Blocked IP: {ip_str}" + return None + + +def validate_navigation_url(url: str) -> tuple[str | None, str | None]: + trimmed = url.strip() + if not trimmed: + return None, "Empty URL" + try: + with_proto = trimmed if "://" in trimmed else f"https://{trimmed}" + parsed = urlparse(with_proto) + except Exception: + return None, "Invalid URL" + if parsed.scheme not in ("http", "https"): + return None, f"Unsupported scheme: {parsed.scheme}" + if parsed.username or parsed.password: + return None, "Credentials in URL are not allowed" + if not parsed.hostname: + return None, "Missing hostname" + host_err = resolve_host_blocked(parsed.hostname) + if host_err: + return None, host_err + return parsed.geturl(), None diff --git a/browser-worker/config.py b/browser-worker/config.py new file mode 100644 index 00000000..9f5a4b95 --- /dev/null +++ b/browser-worker/config.py @@ -0,0 +1,22 @@ +"""Browser Worker configuration.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class WorkerSettings(BaseSettings): + WORKER_SECRET: str = "" + WORKER_BIND: str = "0.0.0.0:8443" + WORKER_MAX_SESSIONS: int = 2 + WORKER_IDLE_TIMEOUT_SEC: int = 1800 + # POST /v1/sessions without WS attach — reclaim zombie reservations + WORKER_RESERVE_TIMEOUT_SEC: int = 120 + # false + Xvfb — better for 阿里云等验证码;true 省资源 + WORKER_HEADLESS: str = "false" + WORKER_SCREENCAST_QUALITY: int = 90 + WORKER_DEFAULT_VIEWPORT_W: int = 1280 + WORKER_DEFAULT_VIEWPORT_H: int = 720 + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + +settings = WorkerSettings() diff --git a/browser-worker/docker-compose.yml b/browser-worker/docker-compose.yml new file mode 100644 index 00000000..05a56727 --- /dev/null +++ b/browser-worker/docker-compose.yml @@ -0,0 +1,11 @@ +services: + browser-worker: + build: . + container_name: nexus-browser-worker + restart: unless-stopped + env_file: + - .env + ports: + - "8443:8443" + shm_size: "1gb" + ipc: host diff --git a/browser-worker/docker-entrypoint.sh b/browser-worker/docker-entrypoint.sh new file mode 100644 index 00000000..b95c8d71 --- /dev/null +++ b/browser-worker/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +# Headed Chromium needs a virtual display (CAPTCHA / anti-bot). +if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then + export DISPLAY="${DISPLAY:-:99}" + if ! pgrep -x Xvfb >/dev/null 2>&1; then + Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset & + sleep 1 + fi +fi +exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info diff --git a/browser-worker/main.py b/browser-worker/main.py new file mode 100644 index 00000000..dc492922 --- /dev/null +++ b/browser-worker/main.py @@ -0,0 +1,147 @@ +"""Browser Worker — Playwright Chromium + WebSocket stream.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field + +from config import settings +from session_manager import manager + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("browser_worker") + + +def _check_key(header: str | None) -> None: + if not settings.WORKER_SECRET: + raise HTTPException(status_code=503, detail="Worker secret not configured") + if not header or header != settings.WORKER_SECRET: + raise HTTPException(status_code=401, detail="Invalid worker key") + + +class CreateSessionBody(BaseModel): + session_id: str = Field(min_length=8, max_length=64) + viewport_w: int = Field(default=1280, ge=320, le=1920) + viewport_h: int = Field(default=720, ge=240, le=1080) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + await manager.start() + logger.info("Playwright Chromium ready") + yield + await manager.stop() + + +app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan) + + +@app.get("/health") +async def health(): + return { + "status": "ok", + "sessions": manager.session_count(), + "max_sessions": settings.WORKER_MAX_SESSIONS, + } + + +@app.post("/v1/sessions") +async def create_session( + body: CreateSessionBody, + x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"), +): + _check_key(x_nexus_worker_key) + try: + await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h) + except ValueError as exc: + code = str(exc) + if code == "max_sessions": + raise HTTPException(status_code=503, detail="Max sessions reached") from exc + if code == "session_exists": + raise HTTPException(status_code=409, detail="Session already exists") from exc + raise HTTPException(status_code=400, detail=code) from exc + return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h} + + +@app.delete("/v1/sessions/{session_id}") +async def delete_session( + session_id: str, + x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"), +): + _check_key(x_nexus_worker_key) + if not await manager.destroy_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"ok": True} + + +@app.websocket("/v1/sessions/{session_id}/stream") +async def session_stream( + websocket: WebSocket, + session_id: str, + x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"), +): + key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key") + if not settings.WORKER_SECRET or key != settings.WORKER_SECRET: + await websocket.close(code=4401, reason="Invalid worker key") + return + + if not manager.get_session(session_id): + await websocket.close(code=4404, reason="Session not found") + return + + await websocket.accept() + + outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + async def send(msg: dict[str, Any]) -> None: + await outbound.put(msg) + + try: + await manager.attach_stream(session_id, send) + except ValueError: + await websocket.close(code=4404, reason="Session not found") + return + except RuntimeError: + await manager.destroy_session(session_id) + await websocket.close(code=4503, reason="Browser not ready") + return + + async def pump_out() -> None: + while True: + msg = await outbound.get() + await websocket.send_json(msg) + + pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}") + try: + while True: + raw = await websocket.receive_text() + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"}) + continue + if not isinstance(msg, dict): + continue + await manager.handle_message(session_id, msg) + except WebSocketDisconnect: + pass + finally: + pump_task.cancel() + try: + await pump_task + except asyncio.CancelledError: + pass + await manager.destroy_session(session_id) + + +if __name__ == "__main__": + import uvicorn + + host, _, port = settings.WORKER_BIND.partition(":") + uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info") diff --git a/browser-worker/requirements.txt b/browser-worker/requirements.txt new file mode 100644 index 00000000..c9ffbdd2 --- /dev/null +++ b/browser-worker/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +playwright==1.49.1 +pydantic-settings==2.7.1 diff --git a/browser-worker/session_manager.py b/browser-worker/session_manager.py new file mode 100644 index 00000000..fea86e54 --- /dev/null +++ b/browser-worker/session_manager.py @@ -0,0 +1,346 @@ +"""Playwright Chromium session manager with CDP screencast.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Awaitable + +from playwright.async_api import Browser, BrowserContext, Page, async_playwright + +from browser_stealth import ( + CHROMIUM_ARGS, + CONTEXT_OPTIONS, + IGNORE_DEFAULT_ARGS, + STEALTH_INIT_SCRIPT, +) +from browser_url_safe import validate_navigation_url +from config import settings + + +def _headless() -> bool: + return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes") + +logger = logging.getLogger("browser_worker.session") + +SendFn = Callable[[dict[str, Any]], Awaitable[None]] + + +@dataclass +class BrowserSession: + session_id: str + viewport_w: int + viewport_h: int + send: SendFn + ready: bool = False + context: BrowserContext | None = None + page: Page | None = None + cdp: Any = None + last_activity: float = field(default_factory=time.monotonic) + reserved_at: float = field(default_factory=time.monotonic) + _screencast_task: asyncio.Task | None = None + _title_task: asyncio.Task | None = None + + async def touch(self) -> None: + self.last_activity = time.monotonic() + + async def close(self) -> None: + for task in (self._screencast_task, self._title_task): + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + self._screencast_task = None + self._title_task = None + if self.cdp: + try: + await self.cdp.send("Page.stopScreencast") + except Exception: + pass + self.cdp = None + if self.context: + try: + await self.context.close() + except Exception as exc: + logger.warning("context close failed session=%s: %s", self.session_id, exc) + self.context = None + self.page = None + self.ready = False + + +class SessionManager: + def __init__(self) -> None: + self._browser: Browser | None = None + self._playwright = None + self._sessions: dict[str, BrowserSession] = {} + self._lock = asyncio.Lock() + self._idle_task: asyncio.Task | None = None + + async def start(self) -> None: + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.launch( + headless=_headless(), + args=CHROMIUM_ARGS, + ignore_default_args=IGNORE_DEFAULT_ARGS, + ) + self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle") + + async def stop(self) -> None: + if self._idle_task: + self._idle_task.cancel() + try: + await self._idle_task + except asyncio.CancelledError: + pass + for sid in list(self._sessions): + await self.destroy_session(sid) + if self._browser: + await self._browser.close() + self._browser = None + if self._playwright: + await self._playwright.stop() + self._playwright = None + + async def _idle_loop(self) -> None: + while True: + await asyncio.sleep(60) + now = time.monotonic() + stale: list[str] = [] + for sid, sess in self._sessions.items(): + if sess.ready: + if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC: + stale.append(sid) + elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC: + stale.append(sid) + for sid in stale: + logger.info("idle timeout session=%s", sid) + await self.destroy_session(sid) + + def session_count(self) -> int: + return len(self._sessions) + + async def reserve_session( + self, + session_id: str, + viewport_w: int, + viewport_h: int, + ) -> None: + async with self._lock: + if session_id in self._sessions: + raise ValueError("session_exists") + if len(self._sessions) >= settings.WORKER_MAX_SESSIONS: + raise ValueError("max_sessions") + self._sessions[session_id] = BrowserSession( + session_id=session_id, + viewport_w=viewport_w, + viewport_h=viewport_h, + send=self._noop_send, + ) + + async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession: + async with self._lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError("session_not_found") + if session.ready: + session.send = send + return session + if not self._browser: + raise RuntimeError("browser_not_ready") + session.send = send + + context = await self._browser.new_context( + viewport={"width": session.viewport_w, "height": session.viewport_h}, + ignore_https_errors=False, + **CONTEXT_OPTIONS, + ) + await context.add_init_script(STEALTH_INIT_SCRIPT) + page = await context.new_page() + session.context = context + session.page = page + cdp = await context.new_cdp_session(page) + session.cdp = cdp + await cdp.send("Page.startScreencast", { + "format": "jpeg", + "quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)), + "everyNthFrame": 1, + }) + session._screencast_task = asyncio.create_task( + self._screencast_loop(session, cdp), + name=f"screencast_{session_id}", + ) + session._title_task = asyncio.create_task( + self._title_loop(session), + name=f"title_{session_id}", + ) + session.ready = True + await session.send({ + "type": "BROWSER_INIT", + "session_id": session_id, + "viewport_w": session.viewport_w, + "viewport_h": session.viewport_h, + }) + return session + + async def _noop_send(self, _msg: dict[str, Any]) -> None: + return + + async def destroy_session(self, session_id: str) -> bool: + async with self._lock: + session = self._sessions.pop(session_id, None) + if not session: + return False + await session.close() + return True + + def get_session(self, session_id: str) -> BrowserSession | None: + return self._sessions.get(session_id) + + async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None: + """CDPSession has no wait_for_event — use on() + asyncio.Queue.""" + frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + def _on_screencast_frame(params: dict[str, Any]) -> None: + try: + frame_queue.put_nowait(params) + except asyncio.QueueFull: + pass + + cdp.on("Page.screencastFrame", _on_screencast_frame) + try: + while session.session_id in self._sessions and session.ready: + try: + msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0) + except asyncio.TimeoutError: + continue + await session.touch() + await session.send({ + "type": "SCREENCAST_FRAME", + "data_b64": msg.get("data", ""), + "metadata": msg.get("metadata") or {}, + }) + await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]}) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("screencast ended session=%s: %s", session.session_id, exc) + + async def _title_loop(self, session: BrowserSession) -> None: + page = session.page + if not page: + return + last_url = "" + last_title = "" + try: + while session.session_id in self._sessions and session.ready: + await asyncio.sleep(1) + await session.touch() + try: + url = page.url + title = await page.title() + except Exception: + continue + if url != last_url or title != last_title: + last_url = url + last_title = title + await session.send({ + "type": "PAGE_INFO", + "url": url, + "title": title, + }) + except asyncio.CancelledError: + raise + + async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None: + session = self.get_session(session_id) + if not session or not session.page or not session.ready: + return + await session.touch() + page = session.page + mtype = msg.get("type") + + if mtype == "PING": + await session.send({"type": "PONG"}) + return + + if mtype == "NAVIGATE": + url = msg.get("url", "") + normalized, err = validate_navigation_url(str(url)) + if err: + await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"}) + return + try: + await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000) + await session.send({ + "type": "PAGE_INFO", + "url": page.url, + "title": await page.title(), + }) + except Exception as exc: + await session.send({ + "type": "ERROR", + "message": str(exc)[:500], + "code": "NAVIGATE_FAILED", + }) + return + + if mtype == "RELOAD": + await page.reload(wait_until="domcontentloaded", timeout=30_000) + return + if mtype == "GO_BACK": + await page.go_back(wait_until="domcontentloaded", timeout=30_000) + return + if mtype == "GO_FORWARD": + await page.go_forward(wait_until="domcontentloaded", timeout=30_000) + return + + if mtype == "VIEWPORT": + w = int(msg.get("width") or session.viewport_w) + h = int(msg.get("height") or session.viewport_h) + w = max(320, min(w, 1920)) + h = max(240, min(h, 1080)) + session.viewport_w = w + session.viewport_h = h + await page.set_viewport_size({"width": w, "height": h}) + return + + if mtype == "MOUSE": + event = msg.get("event") + x = float(msg.get("x", 0)) + y = float(msg.get("y", 0)) + button = msg.get("button", "left") + steps = int(msg.get("steps") or 1) + steps = max(1, min(steps, 25)) + if event == "move": + await page.mouse.move(x, y, steps=steps) + elif event == "down": + await page.mouse.move(x, y) + await page.mouse.down(button=button) + elif event == "up": + await page.mouse.move(x, y) + await page.mouse.up(button=button) + elif event == "click": + await page.mouse.click(x, y, button=button, delay=50) + elif event == "wheel": + delta_y = float(msg.get("delta_y", 0)) + await page.mouse.wheel(0, delta_y) + return + + if mtype == "KEY": + event = msg.get("event") + key = msg.get("key", "") + text = msg.get("text", "") + if event == "down" and key: + await page.keyboard.down(key) + elif event == "up" and key: + await page.keyboard.up(key) + elif event == "type" and text: + await page.keyboard.type(text) + return + + +manager = SessionManager() diff --git a/docs/audit/2026-06-12-remote-browser-worker.md b/docs/audit/2026-06-12-remote-browser-worker.md new file mode 100644 index 00000000..d81d7d63 --- /dev/null +++ b/docs/audit/2026-06-12-remote-browser-worker.md @@ -0,0 +1,126 @@ +# 审计 — 远程浏览器(Playwright Worker) + +**Changelog**: `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` +**设计 SSOT**: `docs/design/specs/2026-06-11-server-browser-chromium-design.md` +**范围**: 2026-06-12 新增/恢复的浏览器 Worker 全链路(未含 `web/app` 构建产物) + +## 范围(文件清单) + +| 文件 | 行数 | 职责 | +|------|------|------| +| `browser-worker/main.py` | 147 | Worker HTTP/WS API | +| `browser-worker/session_manager.py` | 323 | Playwright + screencast | +| `browser-worker/browser_url_safe.py` | 79 | SSRF(Worker 侧) | +| `server/api/browser_session.py` | 198 | 桥接 REST + `/ws/browser` | +| `server/infrastructure/browser/worker_client.py` | 83 | 连 Worker | +| `server/infrastructure/browser/session_registry.py` | 52 | 内存会话归属 | +| `server/utils/browser_url_safe.py` | 80 | SSRF(桥接层) | +| `server/api/browser.py` | 88 | UI 状态 `/api/browser/state` | +| `server/utils/browser_ui_state.py` | 165 | 状态解析 | +| `frontend/src/composables/useRemoteBrowserSession.ts` | 213 | canvas + WS | +| `frontend/src/components/GlobalBrowserPanel.vue` | 380 | 浮动面板 | +| `tests/test_browser_url_safe.py` | 32 | SSRF 单测 | +| `tests/test_browser_ui_state.py` | 35 | UI 状态单测 | + +## API / WS 入口表 + +| 方法 | 路径 | 鉴权 | +|------|------|------| +| GET | `/api/browser/status` | JWT `get_current_admin` | +| POST | `/api/browser/sessions` | JWT | +| DELETE | `/api/browser/sessions/{id}` | JWT + 会话归属 | +| GET/PUT | `/api/browser/state` | JWT + `admin_ui_preferences` | +| WS | `/ws/browser/{session_id}?token=` | JWT + 会话归属 | +| GET | Worker `/health` | **无** | +| POST/DELETE | Worker `/v1/sessions*` | `X-Nexus-Worker-Key` | +| WS | Worker `/v1/sessions/{id}/stream` | Worker Key(Header) | + +## Step 3 规则扫描 + +| ID | 规则 | 结论 | +|----|------|------| +| H1 | SSRF / 私网访问 | **RISK** — 见 F1、F2 | +| H2 | 鉴权 / IDOR | PASS — WS 校验 admin_id | +| H3 | 密钥不落库/不入 git | PASS — `.env` / 持久卷 | +| H4 | 无静默吞错 | **RISK** — 见 F4 | +| H5 | Worker 暴露面 | **RISK** — 见 F3 | +| H6 | 多 worker 一致性 | **RISK** — 见 F5 | +| H7 | UI 状态 URL 白名单 | PASS — `browser_ui_state` | +| H8 | 设计符合度(redirect) | **GAP** — 见 F1 | + +## Closure(全表) + +| ID | 判定 | 严重度 | 位置 | 说明 | +|----|------|--------|------|------| +| H1 | FINDING | **P1** | `browser-worker/session_manager.py` NAVIGATE 后 | **F1** 页内点击/重定向未逐跳 SSRF 校验;设计文档要求 redirect 后重验 | +| H1 | FINDING | **P1** | Worker 部署 ufw | **F3** 装机时额外 `ufw allow 8443/tcp`(全网),削弱「仅主站 IP」隔离 | +| H2 | SAFE | — | `browser_session.py:125-127` | `session_id` 必须属于当前 `admin.id` | +| H2 | SAFE | — | `browser_session.py:95-104` | 桥接层 NAVIGATE 二次校验 | +| H3 | SAFE | — | `worker_client.py`, Worker `.env` | `WORKER_SECRET` 仅环境变量 | +| H4 | FINDING | P2 | `worker_client.py:67-68` | **F4** `delete_worker_session` 网络失败仅 warning,可能残留 Worker 会话 | +| H5 | FINDING | P2 | `browser-worker/main.py:45-51` | `/health` 无认证;若 8443 对公网可见会泄露会话数 | +| H6 | FINDING | P2 | `session_registry.py` | **F5** 内存注册表不跨 uvicorn worker;多进程下 WS 可能 4403 | +| H7 | SAFE | — | `browser_ui_state.py` | http(s) only,无凭据 URL | +| H8 | SAFE | — | screencast 热修 | `cdp.on("Page.screencastFrame")` 已替代错误 API | +| — | SAFE | — | `browser_session.py:61-64` | 创建前清理陈旧会话,避免 409 | +| — | SAFE | — | 前端 | 不再 iframe;canvas 远程渲染 | +| — | SAFE | — | CUD 审计 | 首版未记 session 审计,与设计「与 RDP 一致」一致 | + +### F1 — 重定向 / 页内导航 SSRF 缺口(P1) + +`validate_navigation_url` 仅在显式 `NAVIGATE` 消息时调用。用户在远程 Chromium 内**点击链接**或 **302 跳转**到 `http://10.x` / metadata 时,Playwright 会照常请求,**不经过**校验。 + +**建议**:`page.on("framenavigated")` / `response` 监听,每跳对 `page.url` 调用 `validate_navigation_url`;失败则 `page.close()` 或 `about:blank` 并 WS `ERROR`。 + +### F3 — Worker 防火墙过宽(P1) + +部署日志显示除 `from 20.24.218.235` 外还有 `ufw allow 8443/tcp`(Anywhere)。攻击者若猜到 `WORKER_SECRET`(或暴力)可直连 Worker。 + +**建议**:删除全网 8443 规则,仅保留主站源 IP;`/health` 改内网或加 Key。 + +### F4 — Worker 删除失败(P2) + +`delete_worker_session` 异常时静默;依赖 Worker 空闲超时清理。 + +### F5 — 多 uvicorn worker(P2) + +`session_registry` 进程内字典;生产若 `--workers>1` 可能 POST 在 worker A、WS 落到 worker B。当前 Docker 单进程可接受;扩 worker 时需 Redis 注册表。 + +## 外部输入 → Sink + +| 输入 | Sink | 缓解 | +|------|------|------| +| `NAVIGATE.url` | Playwright `goto` | 双端 `validate_navigation_url` | +| 页内链接点击 | Playwright 导航 | **未缓解(F1)** | +| WS `MOUSE`/`KEY` | Chromium | 仅已认证管理员 | +| `browser/state` tabs URL | MySQL JSON | `_is_safe_url` 过滤 | +| Worker Key | Worker API | 常量时间比较(`!=`) | + +## DoD + +| 项 | 状态 | +|----|------| +| `pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py` | 7 passed(生产机);本地沙箱 DNS 失败属环境限制 | +| `npm run type-check` | 已通过(实现日) | +| 生产 `GET /api/browser/status` | `enabled:true`, `worker_ok:true` | +| 生产 screencast 冒烟 | `SCREENCAST_OK` ~4KB JPEG | +| changelog | `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` | + +## 总结 + +| 级别 | 数量 | 处置建议 | +|------|------|----------| +| P0 | 0 | — | +| P1 | 2 | F1 redirect/页内 SSRF;F3 收紧 ufw | +| P2 | 3 | F4/F5 可排期;/health 加固 | + +**合并结论**:功能链路可用,鉴权与显式 NAVIGATE SSRF 达标;**未达设计文档「每跳 redirect 校验」**,且 Worker **8443 应对公网收口**。建议先修 F3(运维),再实现 F1(代码)后复审计。 + +## 验证命令 + +```bash +venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q +cd frontend && npm run type-check +# 生产 +curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status +``` diff --git a/docs/changelog/2026-06-11-global-floating-browser.md b/docs/changelog/2026-06-11-global-floating-browser.md index 1b9d62cd..59d2ee93 100644 --- a/docs/changelog/2026-06-11-global-floating-browser.md +++ b/docs/changelog/2026-06-11-global-floating-browser.md @@ -46,3 +46,7 @@ cd frontend && npm run type-check ## 说明 iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。 + +## 待办(已记录需求) + +- **网站导航 / 收藏**:用户可自建固定站点列表,见 [设计说明](../design/specs/2026-06-11-browser-nav-bookmarks-design.md) · [实施计划](../design/plans/2026-06-11-browser-nav-bookmarks.md)(后端 `nav_links` 已预留,前端 UI 待做) diff --git a/docs/changelog/2026-06-12-browser-captcha-stealth.md b/docs/changelog/2026-06-12-browser-captcha-stealth.md new file mode 100644 index 00000000..1a317079 --- /dev/null +++ b/docs/changelog/2026-06-12-browser-captcha-stealth.md @@ -0,0 +1,48 @@ +# 2026-06-12 远程浏览器 — 验证码 / 反自动化优化 + +## 摘要 + +针对阿里云等站点验证码失败:Worker 改为 headed + Xvfb + 反自动化指纹;前端滑块拖动支持窗口级鼠标跟踪与平滑步进。 + +## 动机 + +用户反馈阿里云验证码过不去。根因非 Nexus URL 拦截,而是 headless Chromium 被风控识别,且拖滑块时鼠标移出 canvas 会丢失 move 事件。 + +## 涉及文件 + +| 区域 | 文件 | +|------|------| +| Worker | `browser_stealth.py`, `session_manager.py`, `config.py`, `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` | +| 前端 | `useRemoteBrowserSession.ts`, `GlobalBrowserPanel.vue` | + +## 变更要点 + +- 去掉 `--enable-automation`,`disable-blink-features=AutomationControlled`,init script 隐藏 `navigator.webdriver` +- 默认 `WORKER_HEADLESS=false`,容器内 Xvfb `:99` +- 中文 locale / Asia/Shanghai / 常见 Chrome UA +- Screencast JPEG quality 90 +- MOUSE:`down/up` 先 move 到位;`move` 支持 `steps`;新增 `click` +- 前端:mousedown 后 `window` 级 mousemove/mouseup,滑块拖出画面仍有效 + +## 部署 + +```bash +# Worker +cd /opt/nexus-browser-worker && docker compose up -d --build + +# 前端 +cd frontend && npx vite build +# sync_webapp_to_container.sh +``` + +Worker `.env` 可选:`WORKER_HEADLESS=false`(默认)、`WORKER_SCREENCAST_QUALITY=90` + +## 验证 + +- 打开 `https://account.aliyun.com` 或控制台登录页,滑块可拖到底 +- 仍失败时:Worker IP 为机房段,阿里云可能加强风控 — 可本机 Chrome 登录后仅浏览公网页,或换住宅 IP Worker + +## 残余限制 + +- 无法保证 100% 通过所有风控(拼图、短信、设备指纹) +- Cookie 在 Worker Profile,与本机浏览器不共享 diff --git a/docs/changelog/2026-06-12-browser-p0-p1-fix-deploy.md b/docs/changelog/2026-06-12-browser-p0-p1-fix-deploy.md new file mode 100644 index 00000000..9e14f970 --- /dev/null +++ b/docs/changelog/2026-06-12-browser-p0-p1-fix-deploy.md @@ -0,0 +1,47 @@ +# 2026-06-12 远程浏览器 P0/P1 修复与生产同步 + +## 摘要 + +修复 Worker 僵尸 reservation 占槽;前端 `vite build` 同步生产;WS 连接失败时主动 DELETE 会话。 + +## 动机 + +Bug 巡查 #1–#3:生产无浏览器 SPA(用户连不上);Worker `sessions:1` 因 `ready=false` 不进 idle 清理。 + +## 变更 + +| 区域 | 内容 | +|------|------| +| `browser-worker/config.py` | `WORKER_RESERVE_TIMEOUT_SEC=120` | +| `browser-worker/session_manager.py` | idle 清理未 attach 的 reservation | +| `browser-worker/main.py` | `attach_stream` RuntimeError 时 `destroy_session` | +| `frontend/.../useRemoteBrowserSession.ts` | WS/未登录失败时 `DELETE /browser/sessions/{id}` | +| 生产 `web/app` | `index-BimRgA6s.js` + BrowserPage 等已 `sync_webapp_to_container.sh` | +| Worker `66.154.115.131` | `docker compose up -d --build`,`/health` → `sessions:0` | + +## 迁移 / 重启 + +- 无 DB 迁移 +- Worker 已重建;主站仅同步 `web/app`(未重建 API 镜像) + +## 验证 + +```bash +pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q +cd frontend && npm run type-check && npx vite build +curl https://api.synaglobal.vip/app/ # index-BimRgA6s.js +ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l' # ≥1 +curl http://66.154.115.131:8443/health # sessions:0 +``` + +浏览器终验:登录 → 左下角地球 → `https://example.com` 有 canvas 画面。 + +## 未在本批修复 + +- F1 页内导航 SSRF(`framenavigated`) +- F3 Worker ufw 公网 8443 +- P2 前进/后退 stack 与 Chromium 历史不同步 + +## 回滚 + +恢复上一版 `web/app` tar;Worker `git checkout` + `docker compose up -d --build`(或保留 idle 修复)。 diff --git a/docs/changelog/2026-06-12-browser-top-appbar.md b/docs/changelog/2026-06-12-browser-top-appbar.md new file mode 100644 index 00000000..ae25ec18 --- /dev/null +++ b/docs/changelog/2026-06-12-browser-top-appbar.md @@ -0,0 +1,19 @@ +# 2026-06-12 浏览器固定顶栏(原悬浮窗) + +## 摘要 + +保留 **原 GlobalBrowserPanel 完整 UI**(标签、地址栏、canvas),固定在 App Bar 正下方全宽展示;移除地球图标、拖动、最小化、缩放角。账号仍在 App Bar 最右侧,**不**把地址栏拆进 toolbar。 + +## 动机 + +用户要的是原悬浮窗形态固定置顶,而非顶栏内嵌地址栏。 + +## 涉及文件 + +- `frontend/src/App.vue` — 顶栏仅账号;`appChromeVars` 为浏览器预留 420px +- `frontend/src/components/GlobalBrowserPanel.vue` — 固定 `top: 64px` 全宽,无拖动/地球图标 +- 删除误加的 `GlobalBrowserAppBar.vue`、`GlobalBrowserDock.vue`、`GlobalBrowserChromeHost.vue`、`useGlobalBrowserChrome.ts` + +## 验证 + +`cd frontend && npm run type-check && npx vite build`;登录后 App Bar 下见完整浏览器窗(标签+地址栏+画面);账号在顶栏最右。 diff --git a/docs/changelog/2026-06-12-remote-browser-worker-deploy.md b/docs/changelog/2026-06-12-remote-browser-worker-deploy.md new file mode 100644 index 00000000..ea9db313 --- /dev/null +++ b/docs/changelog/2026-06-12-remote-browser-worker-deploy.md @@ -0,0 +1,69 @@ +# 2026-06-12 远程浏览器(Playwright Worker)上线 + +## 摘要 + +在 **66.154.115.131** 部署 `browser-worker`(Playwright Chromium),Nexus 主站桥接 WebSocket + canvas 浮动面板,替代已移除的 iframe 方案。 + +## 动机 + +运维需在面板内完整浏览公网站点(不受 X-Frame-Options 限制);Chromium 运行在独立 Worker,隔离 SSRF 与内存压力。 + +## 涉及文件 + +| 区域 | 文件 | +|------|------| +| Worker | `browser-worker/*` | +| 后端 | `server/api/browser.py`, `browser_session.py`, `server/infrastructure/browser/*`, `server/utils/browser_url_safe.py`, `server/utils/browser_ui_state.py`, `server/config.py`, `server/main.py` | +| 前端 | `GlobalBrowserPanel.vue`, `useGlobalBrowser.ts`, `useRemoteBrowserSession.ts`, `BrowserPage.vue`, `App.vue`, `router`, `ServersPage.vue` | +| 测试 | `tests/test_browser_url_safe.py`, `tests/test_browser_ui_state.py` | + +## 部署拓扑 + +``` +管理员 SPA → Nexus (20.24.218.235) → Worker (66.154.115.131:8443) → 公网 +``` + +## 生产配置(勿提交 git) + +主站 `/var/lib/nexus/.env` 与 `docker/.env.prod` 均需: + +```env +NEXUS_BROWSER_ENABLED=1 +NEXUS_BROWSER_WORKER_URL=http://66.154.115.131:8443 +NEXUS_BROWSER_WORKER_SECRET=<与 Worker .env WORKER_SECRET 相同> +NEXUS_BROWSER_MAX_SESSIONS=2 +NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800 +``` + +Worker:`/opt/nexus-browser-worker/.env`(`WORKER_SECRET` 同上) + +防火墙:Worker `8443` 已对 Nexus 主站 IP `20.24.218.235` 放行。 + +## 迁移 / 重启 + +- 无 DB 迁移(复用 `admin_ui_preferences` / `embedded_browser`) +- Worker:`docker compose up -d --build` +- 主站:重建/重启 `nexus-prod-nexus-1` + 同步 `web/app` + +## 验证 + +```bash +venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q +cd frontend && npm run type-check && npx vite build +curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status +# → enabled:true, worker_ok:true +``` + +浏览器:左下角地球按钮 / 侧栏「浏览器」;输入 `https://example.com` 应出现 canvas 画面。 + +## 回滚 + +`NEXUS_BROWSER_ENABLED=0` 重启主站;Worker `docker compose down` 不影响 Nexus 其他功能。 + +## 2026-06-12 热修 — 画面不显示 / 连不上 + +**根因**:Worker `CDPSession` 误用 `wait_for_event`(不存在),screencast 立即崩溃;断线后会话残留导致 `409`。 + +**修复**:`browser-worker/session_manager.py` 改为 `cdp.on("Page.screencastFrame")` + 队列;`POST /api/browser/sessions` 创建前清理该管理员陈旧会话。 + +**验证**:主站容器内 WS 测试 `SCREENCAST_OK`(example.com JPEG ~4KB)。 diff --git a/docs/changelog/2026-06-12-settings-push-complete-sound-default.md b/docs/changelog/2026-06-12-settings-push-complete-sound-default.md new file mode 100644 index 00000000..4815b51f --- /dev/null +++ b/docs/changelog/2026-06-12-settings-push-complete-sound-default.md @@ -0,0 +1,33 @@ +# 2026-06-12 修复 push_complete_sound GET 404 + +## 摘要 + +`GET /api/settings/push_complete_sound` 在 MySQL 尚无该行时返回 404;现对已有默认值的设置项返回默认 JSON。 + +## 动机 + +登录后 `App.vue` 调用 `syncPushSoundFromServer` 触发 404;`script_exec_complete_sound` 同理。 + +## 涉及文件 + +- `server/api/settings.py` — `SETTING_DEFAULTS` + `get_setting` 回退 +- `tests/test_settings_sound_defaults.py` + +## 默认 + +| key | 默认 | +|-----|------| +| `push_complete_sound` | `beep_double` | +| `script_exec_complete_sound` | `beep_double` | +| `theme` | `dark` | + +## 验证 + +```bash +venv/bin/pytest tests/test_settings_sound_defaults.py -q +# 生产(JWT)GET /api/settings/push_complete_sound → 200 {"value":"beep_double",...} +``` + +## 迁移 + +无;首次 PUT 仍会 upsert 入库。 diff --git a/docs/design/plans/2026-06-11-browser-nav-bookmarks.md b/docs/design/plans/2026-06-11-browser-nav-bookmarks.md new file mode 100644 index 00000000..75b2812a --- /dev/null +++ b/docs/design/plans/2026-06-11-browser-nav-bookmarks.md @@ -0,0 +1,43 @@ +# 实施说明 — 浏览器网站导航(收藏) + +> 设计:`docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md` + +## 涉及文件(计划) + +| 文件 | 动作 | +|------|------| +| `server/utils/browser_ui_state.py` | ✅ `nav_links` 解析与上限 | +| `server/api/browser.py` | ✅ `BrowserNavLinkState` / PUT 字段 | +| `tests/test_browser_ui_state.py` | ✅ `test_parse_nav_links` | +| `frontend/src/composables/useGlobalBrowser.ts` | `navLinks` + add/update/remove + persist | +| `frontend/src/components/GlobalBrowserPanel.vue` | 侧栏 Tab、列表、添加/编辑对话框、空白页快捷入口 | +| `docs/changelog/2026-06-11-browser-nav-bookmarks.md` | 完成后记录 | + +## 前端步骤 + +1. `BrowserNavLink` 类型与 `applyServerState` / `persistState` 带上 `nav_links` +2. `addNavLink(title, url)` · `updateNavLink(id, …)` · `removeNavLink(id)` +3. `addCurrentPageToNav()` — 取 `activeTab.url` 或 `addressDraft` +4. 侧栏:`sidebarMode: 'nav' | 'history' | null` +5. 对话框:`v-dialog` 名称 + URL,`normalizeBrowserUrl` 校验 +6. 空白页:`v-chip` / 列表展示 `navLinks`,点击 `openUrl` + +## 测试要点 + +```bash +pytest tests/test_browser_ui_state.py -q +cd frontend && npm run type-check +``` + +- 添加 3 条导航 → PUT → GET 往返一致 +- 超 64 条截断;非法 URL 过滤 +- UI:编辑后刷新仍显示 + +## 回滚 + +- 前端未发版:仅后端多返回空 `nav_links`,兼容 +- 回滚前端:导航 UI 消失,DB 中 JSON 保留无害 + +## 依赖 + +- 全局浮动浏览器(`GlobalBrowserPanel` + `/api/browser/state`)已上线 diff --git a/docs/design/plans/2026-06-11-browser-worker-server-procurement.md b/docs/design/plans/2026-06-11-browser-worker-server-procurement.md new file mode 100644 index 00000000..c884a76f --- /dev/null +++ b/docs/design/plans/2026-06-11-browser-worker-server-procurement.md @@ -0,0 +1,253 @@ +# 浏览器 Worker 独立服务器 — 采购与部署计划 + +> **目标**:在**单独一台 VPS** 上运行 Playwright Chromium,Nexus 主站(`api.synaglobal.vip`)通过内网 API 桥接,管理员在浮动面板 canvas 操作。 +> **设计**:[`2026-06-11-server-browser-chromium-design.md`](../specs/2026-06-11-server-browser-chromium-design.md) +> **状态**:待购机 → 装机 → Nexus 代码联调 → 生产接入 + +--- + +## 1. 为什么要单独买一台 + +| 项 | 全部塞进 Nexus 主站 | **独立 Worker(本计划)** | +|----|---------------------|---------------------------| +| 主站内存 | 每会话 +200~500MB | 主站几乎不增 | +| Docker 镜像 | +300~500MB | 主镜像不变 | +| SSRF 风险面 | 与 API/DB 同机 | 隔离在 Worker | +| 扩缩容 | 和 API 绑死 | 可单独升配 Worker | + +你已确认:**任意公网 https + Chromium**;**不要 iframe**。Worker 分离是推荐部署形态。 + +--- + +## 2. 购机规格(下单前对照) + +### 2.1 最低可用(单人运维、≤2 并发会话) + +| 项 | 建议 | +|----|------| +| **CPU** | 2 vCPU(x86_64) | +| **内存** | **4 GB**(Chromium 单会话约 300~800MB,留系统与缓冲) | +| **磁盘** | 40 GB SSD(系统 + Chromium 缓存) | +| **系统** | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(x86_64) | +| **带宽** | ≥ 5 Mbps 出站(画面 JPEG 流;10 Mbps 更稳) | +| **地域** | 与 Nexus 主站**同区域或近区**(降延迟) | + +### 2.2 推荐配置(留余量、偶尔双标签) + +| 项 | 建议 | +|----|------| +| CPU | 4 vCPU | +| 内存 | **8 GB** | +| 磁盘 | 60 GB SSD | + +### 2.3 不建议 + +- **1 GB / 2 GB 内存** — Chromium 易 OOM,会话被 kill +- **ARM 除非验证 Playwright** — 优先 x86_64,减少兼容问题 +- **与 Nexus 主站共用同一台** — 违背隔离目的(若预算极紧可临时同机,不推荐) + +### 2.4 厂商与形态 + +任意支持 **Ubuntu + 公网 IP** 的 VPS 即可(Azure、阿里云、腾讯云、Vultr 等)。 +**不必**买 Windows;Worker 只跑 Linux + Docker。 + +--- + +## 3. 网络与安全(购机时一并规划) + +### 3.1 拓扑 + +``` +[ 管理员浏览器 ] ──HTTPS──► [ Nexus 主站 20.24.218.235 :443 ] + │ + 内网/VPN/专线(推荐) + ▼ + [ Browser Worker :8443 ] + │ + └──► 任意公网站点 +``` + +### 3.2 防火墙(Worker 上) + +| 方向 | 规则 | +|------|------| +| 入站 | **仅允许 Nexus 主站 IP** 访问 Worker 服务端口(默认 `8443/tcp`) | +| 入站 | SSH `22/tcp` 仅你的运维 IP(或跳板机) | +| 入站 | **禁止** 0.0.0.0/0 访问 8443 | +| 出站 | 允许 `80/443` 访问公网(浏览器上网) | +| 出站 | 禁止 Worker 访问 Nexus 内网/MySQL/Redis(Worker 不需要) | + +> 若 Nexus 与 Worker **同云厂商同 VPC**,用**内网 IP** 通信,Worker **不要绑公网 8443**。 + +### 3.3 密钥 + +购机后生成一对: + +- `NEXUS_BROWSER_WORKER_SECRET` — 随机 ≥ 32 字节,Worker 与 Nexus 主站 `.env` **各存一份相同值** +- Worker **不**存 Nexus `SECRET_KEY` / `DATABASE_URL` + +--- + +## 4. 购机后你要收集的信息(交给 Agent 联调) + +买好后请记录(可写在本地 `.env` 片段,**勿提交 git**): + +```bash +# Browser Worker(新机器) +BROWSER_WORKER_PUBLIC_IP= # 若无内网则用 +BROWSER_WORKER_PRIVATE_IP= # 与 Nexus 同 VPC 时优先 +BROWSER_WORKER_SSH=user@ip +BROWSER_WORKER_SSH_KEY=/path/to/key.pem + +# Nexus 主站侧(deploy/nexus-1panel.secrets.sh 或生产 .env) +NEXUS_BROWSER_ENABLED=1 +NEXUS_BROWSER_WORKER_URL=https://:8443 +NEXUS_BROWSER_WORKER_SECRET=<与 Worker 相同> +NEXUS_BROWSER_MAX_SESSIONS=2 +NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800 +``` + +并确认: + +- [ ] 从 **Nexus 主站** `curl -k https://:8443/health` 能通(部署 Worker 后) +- [ ] 从 **公网随机 IP** 访问 Worker 8443 **应失败**(防火墙生效) + +--- + +## 5. Worker 机上安装步骤(购机后执行) + +> 代码侧将提供 `browser-worker/` 目录与 `docker-compose.worker.yml`;以下为计划步骤,**Agent 实现后按 changelog 为准**。 + +### Phase A — 基础环境(约 15 分钟) + +```bash +# 1. SSH 登录新机器 +ssh -i key.pem ubuntu@ + +# 2. 系统更新 + Docker +sudo apt-get update && sudo apt-get upgrade -y +sudo apt-get install -y ca-certificates curl +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +# 重新登录使 docker 组生效 + +# 3. 防火墙(示例 ufw) +sudo ufw default deny incoming +sudo ufw allow from to any port 8443 proto tcp +sudo ufw allow from to any port 22 proto tcp +sudo ufw enable +``` + +### Phase B — 部署 Browser Worker 容器(Agent 交付后) + +```bash +# 在 Worker 上 +git clone http://66.154.115.8:3000/admin/Nexus.git /opt/nexus-worker +cd /opt/nexus-worker/browser-worker # 待实现路径 +cp .env.example .env +# 编辑 WORKER_SECRET、BIND、日志级别 +docker compose up -d --build +docker compose logs -f # 确认 Chromium 就绪 +curl -s http://127.0.0.1:8443/health # 应返回 ok +``` + +### Phase C — Nexus 主站接入(Agent 联调) + +```bash +# 在 Nexus 主站 .env 增加 NEXUS_BROWSER_* +# 重启 API:supervisorctl restart nexus 或 docker compose restart nexus +bash scripts/local_verify.sh # 开发机 +# 生产:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh +``` + +### Phase D — 验收 + +| # | 检查项 | 通过标准 | +|---|--------|----------| +| 1 | Worker health | `/health` → ok | +| 2 | 公网站点 | 面板打开 `https://example.com` 有画面 | +| 3 | iframe 曾白屏的站 | 能显示(抽 1~2 个已知站) | +| 4 | SSRF | 面板输入 `http://127.0.0.1` → 明确错误,Worker 日志无成功请求 | +| 5 | 隔离 | 未授权 IP 无法连 Worker 8443 | +| 6 | 持久化 | 刷新 Nexus 后标签/收藏仍在(MySQL) | +| 7 | 重启 Worker | Nexus 会话断开可重建,不拖垮主站 | + +--- + +## 6. Nexus 代码实施顺序(与购机并行) + +你可**先买服务器**;Agent 在仓库内并行开发,**不阻塞购机**。 + +| 阶段 | 内容 | 依赖 Worker 机器 | +|------|------|------------------| +| **W1** | `browser-worker/` 独立服务 + Docker + health | 否(本地 Docker 可测) | +| **W2** | SSRF 校验、Playwright screencast、Worker WS 协议 | 否 | +| **W3** | Nexus 主站 `browser_bridge` 桥接 + `/ws/browser` | 否(mock Worker) | +| **W4** | 前端 canvas 替换 iframe | 否 | +| **W5** | 你在 Worker VPS 部署 + 主站 `.env` 联调 | **是** | +| **W6** | changelog、audit、门控、生产部署 | 是 | + +**购机前可完成 W1~W4**;**W5 需要你的 Worker IP + SSH**。 + +--- + +## 7. Worker 服务接口(预定,实现以代码为准) + +Nexus 主站 → Worker(内网,Header `X-Nexus-Worker-Key`): + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/health` | 就绪探测 | +| POST | `/v1/sessions` | 创建 `{ session_id, viewport }` | +| DELETE | `/v1/sessions/{id}` | 销毁 | +| WS | `/v1/sessions/{id}/stream` | 双向:帧 + navigate/键鼠 | + +管理员浏览器 **只连 Nexus**;不直连 Worker(Worker 不对管理员暴露)。 + +--- + +## 8. 预算参考(仅供参考) + +| 配置 | 大致月费(国内/海外 VPS) | +|------|---------------------------| +| 2C4G | ¥50~150 / $5~20 | +| 4C8G | ¥100~300 / $15~40 | + +另计:出站流量(画面流持续使用时略高于纯 API)。 + +--- + +## 9. 购机 Checklist(打印勾选) + +**下单前** + +- [ ] 2 vCPU + **4 GB RAM** 及以上 +- [ ] Ubuntu 22.04/24.04 x86_64 +- [ ] 与 Nexus 主站同区域或近区 +- [ ] 可配置安全组 / ufw +- [ ] 独立公网 IP 或与 Nexus **同 VPC 内网** + +**到手后** + +- [ ] SSH 可登录 +- [ ] 记录公网 IP / 内网 IP +- [ ] ufw:8443 仅 Nexus 主站 IP +- [ ] 生成 `NEXUS_BROWSER_WORKER_SECRET` +- [ ] 把 §4 信息发给 Agent / 写入本地 secrets +- [ ] 等 `browser-worker/` 合并后执行 Phase B~D + +**回滚** + +- [ ] 主站 `NEXUS_BROWSER_ENABLED=0` 即关闭,Worker 可关机不影响 Nexus + +--- + +## 10. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| `server-browser-chromium-design.md` | 功能与安全 SSOT | +| `server-browser-chromium.md` | Nexus 主站 + 前端实现清单 | +| **本文** | **购机、网络、Worker 装机、联调验收** | + +购机完成后,在新会话说:「Worker 已就绪,IP 是 …」并提供 SSH(secrets 本地),Agent 从 **W5 联调** 继续。 diff --git a/docs/design/plans/2026-06-11-server-browser-chromium.md b/docs/design/plans/2026-06-11-server-browser-chromium.md new file mode 100644 index 00000000..e2255567 --- /dev/null +++ b/docs/design/plans/2026-06-11-server-browser-chromium.md @@ -0,0 +1,102 @@ +# 实施说明 — 服务端 Playwright Chromium 远程浏览器 + +> 设计:`docs/design/specs/2026-06-11-server-browser-chromium-design.md` +> **Worker 购机/装机**:[`2026-06-11-browser-worker-server-procurement.md`](./2026-06-11-browser-worker-server-procurement.md) ← **先买服务器** + +## 部署形态(2026-06-11 更新) + +**选定独立 Worker**,不在 Nexus 主站 `Dockerfile.prod` 安装 Chromium。 + +| 仓库路径 | 运行位置 | +|----------|----------| +| `browser-worker/` | Worker VPS | +| `server/.../browser_bridge.py` 等 | Nexus 主站 | + +## 涉及文件(计划) + +| 文件 | 动作 | 位置 | +|------|------|------| +| `browser-worker/Dockerfile` | **新建** | Worker | +| `browser-worker/docker-compose.yml` | **新建** | Worker | +| `browser-worker/main.py` | Worker FastAPI + WS + Playwright | Worker | +| `browser-worker/browser_url_safe.py` | SSRF(与主站共享逻辑或复制) | Worker | +| `server/utils/browser_url_safe.py` | SSRF(桥接层二次校验) | Nexus | +| `server/infrastructure/browser/worker_client.py` | HTTP/WS 连 Worker | Nexus | +| `server/api/browser_session.py` | REST + 管理员 WS 桥接 | Nexus | +| `server/main.py` | 注册 router;`NEXUS_BROWSER_*` | Nexus | +| `server/config.py` | `browser_enabled`, `browser_worker_url`, `worker_secret` | Nexus | +| `tests/test_browser_url_safe.py` | SSRF 单测 | Nexus | +| `tests/test_browser_worker_client.py` | mock Worker | Nexus | +| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS | 前端 | +| `frontend/src/components/GlobalBrowserPanel.vue` | iframe → canvas | 前端 | +| `frontend/src/composables/useGlobalBrowser.ts` | 对接 remote session | 前端 | + +**不变**:~~`server/api/browser.py`~~ **已删除**(2026-06-11);Worker 方案将新建 `browser_session` API,不复用 iframe 状态接口。 +**不修改**:`Dockerfile.prod`(主站) + +## 实现步骤 + +### Phase W0 — 你购机(并行) + +按 procurement 文档:**2C4G+、Ubuntu 22.04/24.04、防火墙 8443 仅 Nexus IP**。 + +### Phase W1 — Browser Worker 服务 + +1. `browser-worker/` 独立 FastAPI:`GET /health`、`POST /v1/sessions`、`DELETE`、`WS /v1/sessions/{id}/stream` +2. Header `X-Nexus-Worker-Key` 校验 +3. Playwright Chromium + CDP screencast + 键鼠 +4. SSRF 在 Worker 侧强制执行 + +### Phase W2 — Nexus 桥接 + +1. `worker_client.py`:创建/销毁会话、WS 双向转发 +2. `browser_session.py`:管理员 JWT WS ↔ Worker WS +3. `NEXUS_BROWSER_ENABLED=0` → 503 + +### Phase W3 — 前端 + +1. `useRemoteBrowserSession.ts` + canvas +2. `GlobalBrowserPanel` 替换 iframe +3. 503 友好提示 + +### Phase W4 — 联调(需 Worker IP) + +1. Worker 部署 + ufw +2. Nexus `.env` 配 `NEXUS_BROWSER_WORKER_*` +3. 验收表(procurement §5 Phase D) +4. changelog + audit → 门控 → 部署 + +## 测试要点 + +```bash +pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q +cd frontend && npm run type-check +# Worker 目录(实现后) +cd browser-worker && pytest -q +``` + +## 依赖与配置 + +**Nexus 主站** + +```env +NEXUS_BROWSER_ENABLED=1 +NEXUS_BROWSER_WORKER_URL=https://10.x.x.x:8443 +NEXUS_BROWSER_WORKER_SECRET= +NEXUS_BROWSER_MAX_SESSIONS=2 +NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800 +``` + +**Worker `.env`** + +```env +WORKER_SECRET= +WORKER_BIND=0.0.0.0:8443 +WORKER_MAX_SESSIONS=2 +WORKER_IDLE_TIMEOUT_SEC=1800 +``` + +## 回滚 + +1. `NEXUS_BROWSER_ENABLED=0` + 重启 Nexus +2. Worker 容器 `docker compose down`(主站不受影响) diff --git a/docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md b/docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md new file mode 100644 index 00000000..ebdeb0f0 --- /dev/null +++ b/docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md @@ -0,0 +1,112 @@ +# 内置浏览器 — 网站导航(收藏)设计说明 + +> 关联:[2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) · [2026-06-11-global-floating-browser 变更](../../changelog/2026-06-11-global-floating-browser.md) + +## 背景与目标 + +用户希望在 Nexus **全局浮动浏览器**内维护一份**自定义网站导航**(类似浏览器收藏夹),便于反复打开常用运维站点(监控、面板、客户站点等),而不依赖浏览器自带书签。 + +### 用户原话(2026-06-11) + +> 浏览器内置网站导航,我自己可以增加,类似收藏功能。 + +### 与现有能力的关系 + +| 已有 | 导航(收藏) | +|------|----------------| +| **浏览记录** `visits` | 被动、按访问时间倒序,不可编辑名称 | +| **URL 历史** `url_history` | 去重 URL 列表,无自定义标题 | +| **网站导航** `nav_links` | 用户主动添加/编辑/删除,固定名称 + URL,长期入口 | + +三者并存:记录 ≠ 收藏;收藏用于「我关心的固定站点」。 + +## 非目标 + +- 不做浏览器引擎替换(首版 iframe;**2026-06-11 改为服务端 Chromium**,见 [server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)) +- 不做 HTTP 反向代理 +- 不做跨管理员共享导航(按账号隔离,与 `admin_ui_preferences` 一致) +- 首版不做文件夹/分组(可后续 `group` 字段扩展) + +## 数据模型 + +存储:`admin_ui_preferences`,`context = embedded_browser`,字段 `nav_links`(JSON 数组)。 + +```json +{ + "nav_links": [ + { + "id": "uuid", + "title": "Grafana", + "url": "https://grafana.example.com", + "sort_order": 0 + } + ] +} +``` + +约束(后端 `browser_ui_state.py`): + +| 字段 | 规则 | +|------|------| +| `id` | 非空字符串,≤64 | +| `title` | 非空,≤80 | +| `url` | 仅 `http`/`https`,无凭据,与现有 URL 白名单一致 | +| 数量上限 | 64 条 | +| `sort_order` | 整数,用于排序 | + +API:沿用 `GET/PUT /api/browser/state`,`nav_links` 随整体 state 读写(与 tabs、visits 相同)。 + +## 交互设计 + +### 入口 + +1. 浮动浏览器工具栏:**「导航」**按钮(`mdi-star-outline`),与「浏览记录」并列 +2. 侧栏 Tab:**网站导航** | **浏览记录**(同一侧栏区域切换,避免双栏占宽) +3. 空白页:展示导航卡片网格,点击即打开 + +### 用户操作 + +| 操作 | 行为 | +|------|------| +| 添加 | 对话框:名称 + URL;可预填当前地址栏 / 当前页 URL | +| 编辑 | 同对话框,改名称或 URL | +| 删除 | 列表项菜单或删除图标,需确认(可选) | +| 打开 | 点击条目 → 当前标签导航到该 URL | +| 从当前页收藏 | 地址栏旁「星标」一键添加(已存在则提示或进入编辑) | + +### 持久化 + +- 保存至 MySQL,换设备/清 localStorage 不丢 +- 与浏览记录、标签、窗口位置同一 debounce 写入策略 + +## 安全 + +- URL 校验复用 `_is_safe_url`,拒绝 `javascript:`、`data:` 等 +- 无服务端 fetch,无 SSRF +- 按 `admin_id` 隔离 + +## 内核与风控说明(用户关切) + +内置浏览器 **不是** 独立 Firefox/Chrome 安装包,而是 **iframe + 用户当前浏览器引擎**。 + +- User-Agent 与普通浏览器一致,**不会因 Nexus 被单独判为机器人** +- 部分站点禁止 iframe 或要求顶层窗口 → 仍用「新标签打开」 +- 收藏导航仅保存 URL,不改变加载方式 + +## 实现状态(2026-06-11) + +| 层 | 状态 | +|----|------| +| 后端 `nav_links` 解析 / API 字段 | ✅ 已入代码(待前端联调) | +| 前端侧栏 + 增删改 UI | ⏳ 待实现 | +| 单测 `nav_links` 解析 | ✅ `tests/test_browser_ui_state.py::test_parse_nav_links` | +| 部署 | ⏳ 前端完成后一并门控 | + +## 验收标准 + +- [ ] 可添加、编辑、删除导航项,刷新后仍在 +- [ ] 点击导航项在当前标签打开对应 https 站点 +- [ ] 可从当前页一键加入收藏 +- [ ] 非法 URL 拒绝并提示 +- [ ] 不同管理员账号导航互不可见 +- [ ] 与浏览记录、标签、最小化状态可同时正常工作 diff --git a/docs/design/specs/2026-06-11-embedded-browser-design.md b/docs/design/specs/2026-06-11-embedded-browser-design.md index fed7f360..032f090e 100644 --- a/docs/design/specs/2026-06-11-embedded-browser-design.md +++ b/docs/design/specs/2026-06-11-embedded-browser-design.md @@ -12,7 +12,11 @@ | HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie | | 仅外链新标签 | 零风险 | 非「内置」 | -**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。 +**选定(2026-06-11 前)**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。 + +> **2026-06-11 变更**:用户拒绝 iframe 方案,改为 **服务端 Playwright Chromium + WebSocket 画面流**。 +> 见 [2026-06-11-server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)。 +> 本文档仍有效部分:路由入口、URL 白名单原则、UI 状态 API;**iframe 渲染已废弃**。 ## 接口与路由 @@ -28,7 +32,9 @@ ## 验收 -- [ ] 侧栏「浏览器」进入全屏 iframe 页 +- [x] 全局浮动面板、最小化可拖、不可关闭(见 global-floating-browser changelog) +- [ ] 侧栏「浏览器」或左下角入口打开浮动窗 - [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开 - [ ] 服务器列表「站点」跳转并加载域名 - [ ] 非法 URL 拒绝并提示 +- [ ] **网站导航(收藏)** — 见 [browser-nav-bookmarks-design.md](./2026-06-11-browser-nav-bookmarks-design.md) diff --git a/docs/design/specs/2026-06-11-server-browser-chromium-design.md b/docs/design/specs/2026-06-11-server-browser-chromium-design.md new file mode 100644 index 00000000..27a306ee --- /dev/null +++ b/docs/design/specs/2026-06-11-server-browser-chromium-design.md @@ -0,0 +1,173 @@ +# 服务端远程浏览器(Playwright Chromium)— 设计说明 + +> **取代** iframe 渲染方案([2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) 中的页面加载方式)。 +> UI 状态(标签、收藏 `nav_links`、窗口位置)仍走 `/api/browser/state`,不变。 + +## 背景与目标 + +运维需要在 Nexus 浮动面板内**完整浏览任意公网站点**,不受 `X-Frame-Options` / CSP `frame-ancestors` 限制,且使用**服务端独立浏览器内核**,而非用户本机标签页内的 iframe。 + +### 用户确认(2026-06-11) + +| 决策项 | 选择 | +|--------|------| +| 可访问范围 | **任意公网** `http` / `https` | +| 内核 | **Chromium**(Playwright),不要求 Firefox | +| 交互 | 在 Nexus 面板内看画面 + 键鼠操作(非「新标签打开」为主) | + +### 与 iframe 方案对比 + +| | iframe(现状) | 服务端 Chromium(目标) | +|--|----------------|---------------------------| +| 渲染位置 | 用户浏览器 | Nexus 服务器 | +| 站点嵌入限制 | 常被拒绝 | 无(顶层窗口) | +| SSRF 风险 | 无 | **有,必须缓解** | +| 资源 | 几乎无 | 每会话 ~200–500MB RAM | +| Cookie / 登录 | 用户本机 | **服务器浏览器 Profile** | + +## 方案概述 + +类比 RDP(guacd → WebSocket → 前端画布),但 Nexus **拥有**浏览器进程与协议: + +``` +管理员浏览器 (Vue SPA) + │ JWT WebSocket + REST + ▼ +Nexus API (FastAPI) — 桥接,不跑 Chromium + │ Worker Key + 内网 HTTPS + ▼ +Browser Worker VPS — Playwright Chromium(headless + CDP screencast) + ▼ +公网目标站点 +``` + +| 组件 | 职责 | +|------|------| +| **Playwright Chromium** | 运行在 **Browser Worker**;Nexus 桥接会话 | +| **URL 安全网关** | 导航前 DNS 解析 + IP 段校验,阻断私网/元数据 SSRF | +| **`/ws/browser/{session_id}`** | 推送 JPEG 帧;接收 navigate / 键鼠 / 滚动 | +| **`POST/DELETE /api/browser/sessions`** | 创建、销毁会话 | +| **`GlobalBrowserPanel`** | `` 替代 `