feat(browser): 远程浏览器 Worker 桥接与顶栏固定悬浮窗

接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下);
含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 06:49:20 +08:00
parent 2b0413c43e
commit e833b923c8
46 changed files with 3737 additions and 17 deletions
+19
View File
@@ -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"]
+46
View File
@@ -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,
}
+77
View File
@@ -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
+22
View File
@@ -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()
+11
View File
@@ -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
+11
View File
@@ -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
+147
View File
@@ -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")
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
pydantic-settings==2.7.1
+346
View File
@@ -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()
@@ -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 | SSRFWorker 侧) |
| `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 KeyHeader |
## 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 | — | 前端 | 不再 iframecanvas 远程渲染 |
| — | 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 workerP2
`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/页内 SSRFF3 收紧 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
```
@@ -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 待做)
@@ -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,与本机浏览器不共享
@@ -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` tarWorker `git checkout` + `docker compose up -d --build`(或保留 idle 修复)。
@@ -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 下见完整浏览器窗(标签+地址栏+画面);账号在顶栏最右。
@@ -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)。
@@ -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
# 生产(JWTGET /api/settings/push_complete_sound → 200 {"value":"beep_double",...}
```
## 迁移
无;首次 PUT 仍会 upsert 入库。
@@ -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`)已上线
@@ -0,0 +1,253 @@
# 浏览器 Worker 独立服务器 — 采购与部署计划
> **目标**:在**单独一台 VPS** 上运行 Playwright ChromiumNexus 主站(`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 镜像 | +300500MB | 主镜像不变 |
| SSRF 风险面 | 与 API/DB 同机 | 隔离在 Worker |
| 扩缩容 | 和 API 绑死 | 可单独升配 Worker |
你已确认:**任意公网 https + Chromium****不要 iframe**。Worker 分离是推荐部署形态。
---
## 2. 购机规格(下单前对照)
### 2.1 最低可用(单人运维、≤2 并发会话)
| 项 | 建议 |
|----|------|
| **CPU** | 2 vCPUx86_64 |
| **内存** | **4 GB**Chromium 单会话约 300800MB,留系统与缓冲) |
| **磁盘** | 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 等)。
**不必**买 WindowsWorker 只跑 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/RedisWorker 不需要) |
> 若 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://<worker-ip-or-internal>:8443
NEXUS_BROWSER_WORKER_SECRET=<与 Worker 相同>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
并确认:
- [ ]**Nexus 主站** `curl -k https://<worker>: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@<WORKER_IP>
# 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 <NEXUS_MAIN_IP> to any port 8443 proto tcp
sudo ufw allow from <YOUR_OPS_IP> 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_*
# 重启 APIsupervisorctl 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、门控、生产部署 | 是 |
**购机前可完成 W1W4****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 | ¥50150 / $520 |
| 4C8G | ¥100300 / $1540 |
另计:出站流量(画面流持续使用时略高于纯 API)。
---
## 9. 购机 Checklist(打印勾选)
**下单前**
- [ ] 2 vCPU + **4 GB RAM** 及以上
- [ ] Ubuntu 22.04/24.04 x86_64
- [ ] 与 Nexus 主站同区域或近区
- [ ] 可配置安全组 / ufw
- [ ] 独立公网 IP 或与 Nexus **同 VPC 内网**
**到手后**
- [ ] SSH 可登录
- [ ] 记录公网 IP / 内网 IP
- [ ] ufw8443 仅 Nexus 主站 IP
- [ ] 生成 `NEXUS_BROWSER_WORKER_SECRET`
- [ ] 把 §4 信息发给 Agent / 写入本地 secrets
- [ ]`browser-worker/` 合并后执行 Phase BD
**回滚**
- [ ] 主站 `NEXUS_BROWSER_ENABLED=0` 即关闭,Worker 可关机不影响 Nexus
---
## 10. 与现有文档关系
| 文档 | 关系 |
|------|------|
| `server-browser-chromium-design.md` | 功能与安全 SSOT |
| `server-browser-chromium.md` | Nexus 主站 + 前端实现清单 |
| **本文** | **购机、网络、Worker 装机、联调验收** |
购机完成后,在新会话说:「Worker 已就绪,IP 是 …」并提供 SSHsecrets 本地),Agent 从 **W5 联调** 继续。
@@ -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=<shared>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
**Worker `.env`**
```env
WORKER_SECRET=<same-as-above>
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`(主站不受影响)
@@ -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 拒绝并提示
- [ ] 不同管理员账号导航互不可见
- [ ] 与浏览记录、标签、最小化状态可同时正常工作
@@ -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)
@@ -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 风险 | 无 | **有,必须缓解** |
| 资源 | 几乎无 | 每会话 ~200500MB RAM |
| Cookie / 登录 | 用户本机 | **服务器浏览器 Profile** |
## 方案概述
类比 RDPguacd → WebSocket → 前端画布),但 Nexus **拥有**浏览器进程与协议:
```
管理员浏览器 (Vue SPA)
│ JWT WebSocket + REST
Nexus API (FastAPI) — 桥接,不跑 Chromium
│ Worker Key + 内网 HTTPS
Browser Worker VPS — Playwright Chromiumheadless + CDP screencast
公网目标站点
```
| 组件 | 职责 |
|------|------|
| **Playwright Chromium** | 运行在 **Browser Worker**Nexus 桥接会话 |
| **URL 安全网关** | 导航前 DNS 解析 + IP 段校验,阻断私网/元数据 SSRF |
| **`/ws/browser/{session_id}`** | 推送 JPEG 帧;接收 navigate / 键鼠 / 滚动 |
| **`POST/DELETE /api/browser/sessions`** | 创建、销毁会话 |
| **`GlobalBrowserPanel`** | `<canvas>` 替代 `<iframe>`;保留地址栏、标签、收藏、最小化 |
| **`/api/browser/state`** | 不变 — tabs、visits、nav_links、rect |
**刻意不做(首版)**
- 不做 HTTP 反向代理(HTML 改写 / Cookie 注入)
- 不做浏览内容审计或页面录制落库
- 不做多管理员并发压测级扩展(单人运维,全局并发 ≤2)
- 不做本机 Cookie 与服务器 Profile 双向同步
## WebSocket 协议(草案)
鉴权:与终端/RDP 一致,query `?token=` = 常规 access JWT`sub` = admin_id),且 `session` 归属该校验通过的管理员。
### 服务端 → 客户端
| type | 字段 | 说明 |
|------|------|------|
| `BROWSER_INIT` | `session_id`, `viewport_w`, `viewport_h` | 连接就绪 |
| `SCREENCAST_FRAME` | `data_b64`, `metadata` | CDP screencast JPEG |
| `PAGE_INFO` | `url`, `title` | 导航完成或标题变化 |
| `ERROR` | `message`, `code` | URL 拒绝、超时、Playwright 错误 |
| `PONG` | — | 心跳 |
### 客户端 → 服务端
| type | 字段 | 说明 |
|------|------|------|
| `NAVIGATE` | `url` | 经 SSRF 校验后 `page.goto` |
| `RELOAD` / `GO_BACK` / `GO_FORWARD` | — | 历史导航 |
| `MOUSE` | `event`, `x`, `y`, `button` | move / down / up / wheel |
| `KEY` | `event`, `key`, `code`, `text` | down / up / type |
| `VIEWPORT` | `width`, `height` | 面板尺寸变化 |
| `PING` | — | 心跳 |
坐标:`x/y` 为相对 viewport 的 CSS 像素,与 screencast 帧尺寸一致。
## SSRF 防护(任意公网)
导航与每次 redirect 后 **重新校验** 最终 URL
1.`http:` / `https:`;拒绝凭据、`javascript:``data:``file:`
2. 解析 hostname → `getaddrinfo` → **所有**解析 IP 必须不在阻断段:
- `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- `169.254.0.0/16`, `0.0.0.0/8`, `100.64.0.0/10`
- IPv6: `::1`, `fc00::/7`, `fe80::/10`
3. 阻断常见 metadata 主机名(如 `169.254.169.254``*metadata*` 字面)
4. 导航超时 30s;单页资源不限制由 Chromium 自身处理
5. 失败时 WS 返回 `ERROR`**不**静默降级
> 单人运维下 residual riskDNS rebinding 需 TTL 短重查或 redirect 链逐跳校验;首版采用 **redirect 监听 + 每跳校验**。
## 会话与资源
| 策略 | 值 |
|------|-----|
| 每管理员活跃会话 | 1 |
| 全局活跃会话 | ≤2 |
| 空闲超时 | 30 min 无 WS 活动 → 关闭 browser context |
| 视口默认 | 1280×720(可随面板 resize |
| Screencast | CDP `Page.startScreencast`, JPEG quality ~80 |
进程模型:单例 Playwright `Browser` 进程 + 每会话独立 `BrowserContext`(隔离 Cookie)。
## 部署(选定:独立 Worker 服务器)
> **购机与装机计划**[`2026-06-11-browser-worker-server-procurement.md`](../plans/2026-06-11-browser-worker-server-procurement.md)
Playwright Chromium **不装入 Nexus 主站镜像**,而在**单独 VPSBrowser Worker**运行;Nexus 主站仅桥接 WebSocket。
```
管理员浏览器 → Nexus APIJWT)→ Browser WorkerWorker Key)→ 公网
```
| 组件 | 部署位置 |
|------|----------|
| `browser-worker/` 服务 + Chromium | **Worker VPS**2C4G 起) |
| `/api/browser/sessions``/ws/browser/*` 桥接 | **Nexus 主站** |
| MySQL UI 状态 | Nexus 主站(不变) |
**Nexus 主站 `.env`**
```env
NEXUS_BROWSER_ENABLED=1
NEXUS_BROWSER_WORKER_URL=https://<worker-internal-or-vpn>:8443
NEXUS_BROWSER_WORKER_SECRET=<shared-secret>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
**Worker 防火墙**8443 **仅** Nexus 主站 IP;不对公网开放 Worker API。
**降级**`NEXUS_BROWSER_ENABLED=0` 或 Worker 不可达 → `POST /api/browser/sessions` **503**;前端提示「服务端浏览器未启用」。
~~`Dockerfile.prod` 增加 Chromium~~**Worker 模式不修改主站镜像体积**
## 前端变更
- `GlobalBrowserPanel.vue``<iframe>``<canvas>` + `useRemoteBrowserSession.ts`
- 保留:浮动/最小化/不可关闭/重启、标签、历史、收藏(nav_links 待做 UI
- 移除或隐藏:「新标签打开」可保留作 **导出 URL** 辅助,非主路径
- `useGlobalBrowser.ts``navigate` 改为 WS `NAVIGATE``frameKey` 不再需要
## 安全与隐私
- 页面内容经 JPEG 帧过 Nexus,**不持久化**截图
- 服务器 Chromium 内 Cookie 存于容器 ephemeral context(重启会话丢失)
-`get_current_admin` 可创建/连接会话
- CUD:创建/销毁会话可选写 audit(首版 **不做**,与 RDP 无连接审计一致)
## 验收标准
- [ ] 打开 `https://example.com` 等公网站,面板 canvas 有画面且可点击链接
- [ ] 曾 iframe 白屏的站点(如带 `X-Frame-Options: DENY` 的站)可正常显示
- [ ] 访问 `http://127.0.0.1` / `http://10.x` / metadata → 明确 ERROR,不发起请求
- [ ] 刷新页面后会话内 Cookie 保留;重启浏览器会话 Cookie 清空
- [ ] 标签、收藏、窗口位置仍持久化到 `/api/browser/state`
- [ ] `NEXUS_BROWSER_ENABLED=0` 时有可读降级提示
- [ ] Worker VPS 部署完成,防火墙仅 Nexus 主站可连
## 回滚
-`NEXUS_BROWSER_ENABLED=0` → 503,前端可回退 iframe 分支(保留 feature flag 一段过渡期)
- 卸载 Playwright 依赖仅当确认不再使用
+29
View File
@@ -65,3 +65,32 @@ bash scripts/local_verify.sh # Docker + API + smoke + test_api + ruff
```
文档:`docs/project/local-integration-test.md`(已取代 WSL 脚本)
---
## 待办 — 服务端远程浏览器(Worker 分离)
**用户确认**:任意公网;Chromium**独立 Worker 服务器**(用户去购机)。
| 文档 | 路径 |
|------|------|
| 设计 | `docs/design/specs/2026-06-11-server-browser-chromium-design.md` |
| 代码实施 | `docs/design/plans/2026-06-11-server-browser-chromium.md` |
| **购机/装机/联调** | **`docs/design/plans/2026-06-11-browser-worker-server-procurement.md`** |
**进度**iframe 内置浏览器 **已移除**changelog `2026-06-11-remove-embedded-browser`);待购 Worker 后按 W1W4 重建 canvas 浏览器。
**购机规格速记**2C4G 起(推荐 4C8G)· Ubuntu 22.04/24.04 · Worker 8443 仅允许 Nexus 主站 `20.24.218.235` 访问。
---
## 待办 — 内置浏览器网站导航(收藏)
**用户原话**:浏览器内置网站导航,我自己可以增加,类似收藏功能。
| 文档 | 路径 |
|------|------|
| 设计 | `docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md` |
| 实施计划 | `docs/design/plans/2026-06-11-browser-nav-bookmarks.md` |
**进度**:随 iframe 浏览器一并移除;Worker 方案上线时重做。
@@ -0,0 +1,107 @@
# Bug 巡查 — 远程浏览器
**日期**2026-06-12
**范围**`browser-worker/`、桥接 API、前端 canvas 客户端、生产 `66.154.115.131:8443`
**方法**:代码走读 + 单测 + 生产探针复验
**关联**`docs/audit/2026-06-12-remote-browser-worker.md``docs/reports/2026-06-12-remote-browser-patrol.md`
## 发现与处理
| # | 严重度 | 问题 | 根因 | 状态 |
|---|--------|------|------|------|
| 1 | **P0** | 生产无浏览器 UI,用户「连不上」 | 后端 `BROWSER_ENABLED=1`,但 `web/app` **无** `ws/browser` bundle | **已修**`index-BimRgA6s.js` 已同步,容器 grep `ws/browser` ≥1 |
| 2 | **P1** | Worker `sessions:1` 长期占槽 | `ready=false` 不进 idle 清理 | **已修**`WORKER_RESERVE_TIMEOUT_SEC=120` + 重建 Worker`sessions:0` |
| 3 | **P1** | attach 失败留下僵尸 reservation | `RuntimeError``destroy_session` | **已修**`main.py` 失败路径销毁 |
| 4 | **P1** | 页内点击/重定向 SSRF | 仅 `NAVIGATE` 校验;`RELOAD`/`GO_BACK`/`页内导航` 无复验 | **未修**(审计 F1 |
| 5 | **P1** | Worker 8443 公网暴露 `/health` | ufw 过宽 | **未修**(审计 F3 |
| 6 | **P2** | 前进/后退与远程历史不同步 | UI `stack` 仅本地维护;`updateActiveFromRemote` **不**更新 stackcanvas 内点击只改 `url` | **未修** |
| 7 | **P2** | `connect()` 失败后无显式 DELETE | 同上 | **已修** — WS/未登录失败时 `DELETE /browser/sessions/{id}` |
| 8 | **P2** | `attach_stream` 并发竞态 | `ready=false` 时锁在 L140 释放,双 WS 可能各建 `BrowserContext` | 低概率(单桥接) |
| 9 | **P2** | `delete_worker_session` 失败静默 | 日志 warningWorker 会话残留 | **未修**(审计 F4 |
| 10 | P3 | 切标签每次新建 Worker 会话 | `watch(activeTabId)``disconnect()` → 新 `POST` | 设计可接受,略耗资源 |
| 11 | P3 | `persistState` catch 空实现 | 离线时本地状态与 MySQL 可能短暂不一致 | 可接受 |
## P0 — 前端未部署(用户可见)
```
生产容器: BROWSER_ENABLED=1
web/app/assets: grep ws/browser → 0 文件
Worker /health: sessions=1, max_sessions=2
```
管理员打开面板**没有任何**地球按钮 / canvas 客户端代码,与「连不上」完全一致。后端与 Worker 已就绪,**阻塞在 SPA 同步**。
## P1 — Worker 僵尸会话(占槽根因)
### 空闲清理漏掉 `ready=false`
```100:103:browser-worker/session_manager.py
stale = [
sid for sid, sess in self._sessions.items()
if sess.ready and now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC
]
```
`POST /v1/sessions` 只 `reserve_session`Chromium 在 WS `attach_stream` 后才 `ready=True`。
任一路径「POST 成功但 stream 未建立」都会永久占 `session_count`,直至 `max_sessions` 打满。
典型路径:
- 前端未部署时 API 测试 / 桥接探测只 POST 不挂 WS
- Nexus 桥接连 Worker WS 失败(`WORKER_UNREACHABLE`
- Worker `attach_stream` 抛 `RuntimeError` 后:
```110:112:browser-worker/main.py
except RuntimeError:
await websocket.close(code=4503, reason="Browser not ready")
return
```
此处 **未** `destroy_session`reservation 泄漏。
**建议**idle 对 `not ready` 加 `created_at` 超时(如 120s);`RuntimeError`/`attach` 失败路径 `destroy_session`;运维可 `docker restart nexus-browser-worker` 清槽。
## P1 — SSRF / 暴露面(审计复查)
与 `docs/audit/2026-06-12-remote-browser-worker.md` 一致,代码层**无新增修复**。
## P2 — 前进/后退逻辑 Bug
`GlobalBrowserPanel.onGoBack`
```350:353:frontend/src/components/GlobalBrowserPanel.vue
function onGoBack() {
goBack() // 改本地 stack + addressDraft
if (connected.value) remoteGoBack() // Playwright page.go_back()
}
```
- 用户在 canvas 内点击链接 → `PAGE_INFO` → `updateActiveFromRemote` 只更新 `tab.url`**不 push stack**
- `canGoBack` 仍看本地 stack → 按钮状态错误
- 点后退时本地 stack 与 Chromium 历史**可能指向不同 URL**
**建议**:以远程为准时去掉本地 stack,或 `PAGE_INFO` 时同步 push stack;后退只发 `GO_BACK` 不回写本地 stack 直到 `PAGE_INFO`。
## 本地验证
```text
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py → 7 passed
npm run type-check → PASS
curl https://api.synaglobal.vip/health → ok
```
## 建议修复顺序
1. **P0** — `vite build` + `sync_webapp_to_container.sh`(立刻恢复可用性)
2. **P1** — Worker 重启清槽 + 修 reservation 泄漏(idle 未 ready + RuntimeError 清理)
3. **P1** — ufw 收口 8443
4. **P1** — `framenavigated` SSRF
5. **P2** — 历史栈与 `GO_BACK` 统一
## 复巡命令
```bash
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l'
curl -s http://66.154.115.131:8443/health
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
```
@@ -0,0 +1,90 @@
# 巡查 — 远程浏览器 + 生产基线
**日期**2026-06-12
**范围**:生产 `api.synaglobal.vip`、Worker `66.154.115.131:8443`、工作区浏览器改动、审计 F1/F3 闭环
**关联审计**`docs/audit/2026-06-12-remote-browser-worker.md`
## 进度条
| 步骤 | 状态 |
|------|------|
| 生产 `/health` + prod_probe | ✅ 全通过 |
| 浏览器后端配置 | ✅ `BROWSER_ENABLED=1`Worker URL 已配 |
| 浏览器前端 bundle | ❌ **未部署** |
| Worker 可达 / 暴露面 | ⚠️ 公网可访问 `/health` |
| 审计 P1 闭环 | ❌ F1 SSRF、F3 防火墙未修 |
| 工作区 git | ⚠️ 浏览器源码 largely 未 commit |
| 本地单测 | ✅ browser 7 + watch_pins 9 = 16 passed |
## 生产探针(`scripts/prod_health_check.sh`
```text
✓ external /health → ok
✓ external /app/ → HTTP 200
✓ login / health/detail / alert-history / schedules / cron / main bundle
=== All production probe checks passed ===
```
容器:`nexus-prod-nexus-1` **Up ~4min (healthy)**(巡查时刚重启不久)。
## 远程浏览器链路
| 检查项 | 结果 | 说明 |
|--------|------|------|
| 主站 `/health` | `ok` | 公网 + 容器内均 OK |
| 容器 `settings.BROWSER_ENABLED` | `1` | Worker URL `http://66.154.115.131:8443` |
| 主站 → Worker `/health` | `ok` | 容器内 curl 成功 |
| 公网 → Worker `/health` | **可达** | `{"status":"ok","sessions":1,"max_sessions":2}` |
| 无 Key `POST /v1/sessions` | `422` | 非 401,但无密钥无法建会话 |
| 生产 `web/app``GlobalBrowser` / `ws/browser` / screencast | **无** | 146 个 JSgrep 零命中 |
| 侧栏「浏览器」/ 地球按钮 UI | **未上线** | 需 `vite build` + `sync_webapp_to_container.sh` |
**结论**:后端与 Worker **已启用**,但 **SPA 未同步**,管理员面板仍无远程浏览器入口;与「连不上」用户反馈一致(旧 bundle 无 canvas 客户端)。
## 审计项复查(未闭环)
| ID | 审计结论 | 巡查结果 |
|----|----------|----------|
| **F1** | 页内点击/重定向无 SSRF 复验 | **未修**`session_manager.py``NAVIGATE``validate_navigation_url` |
| **F3** | Worker `ufw` 过宽 | **未确认修** — 公网 curl `/health` 成功,8443 仍暴露;Worker SSH 本机无凭据 |
| F4 | delete 失败静默 | 未改 |
| F5 | 内存 session_registry | 可接受(单进程 Docker |
Worker 当前 **`sessions:1`**:可能存在陈旧会话占槽(`max_sessions=2`),建议 Worker 侧重启或调 API 清理。
## 工作区状态
浏览器相关文件多为 **未跟踪 / 未提交**`browser-worker/``GlobalBrowserPanel.vue``browser_session.py` 等)。生产后端已跑新镜像,但 **前端构建产物未进容器**,形成 **前后端版本错位**
本地:
```bash
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py tests/test_watch_pins.py -q
16 passed
```
## 发现汇总
| # | 严重度 | 问题 | 建议 |
|---|--------|------|------|
| 1 | **P0 体验** | 前端未部署,用户无法使用浏览器 | `cd frontend && npx vite build``deploy/sync_webapp_to_container.sh` |
| 2 | **P1 安全** | Worker 8443 + `/health` 公网可见 | 收紧 ufw 仅 `20.24.218.235``/health` 加 Key 或禁公网 |
| 3 | **P1 安全** | F1 页内导航 SSRF 缺口 | `framenavigated` 逐跳校验 |
| 4 | P2 | Worker `sessions:1` 占槽 | 清理会话或等待 idle 超时 |
| 5 | P2 | 源码未 commit | 部署稳定后 `git add` 源码+文档(不含 `web/app/assets` 洪流) |
## 建议执行顺序
1. **同步前端**(解除 P0
2. **收紧 Worker 防火墙**F3
3. **实现 F1** 后复审计
4. **commit** 源码与 changelog/audit/report
## 验证命令(复巡)
```bash
bash scripts/prod_health_check.sh
ssh nexus 'sudo docker exec nexus-prod-nexus-1 python3 -c "from server.config import settings; print(settings.BROWSER_ENABLED)"'
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | head -1'
curl -s http://66.154.115.131:8443/health # 修 F3 后应仅主站可达
```
@@ -0,0 +1,30 @@
# 远程浏览器 Worker 部署验证 — 2026-06-12
## 环境
| 角色 | 地址 |
|------|------|
| Nexus 主站 | `20.24.218.235` / `api.synaglobal.vip` |
| Browser Worker | `66.154.115.131:8443`(主机名 mqtele,约 8GB RAM |
## Worker
| 检查 | 结果 |
|------|------|
| `curl http://66.154.115.131:8443/health`(从主站) | `{"status":"ok","sessions":0,"max_sessions":2}` |
| 容器 | `nexus-browser-worker` running |
| ufw | `8443` 已对 `20.24.218.235` 放行 |
## Nexus API
| 检查 | 结果 |
|------|------|
| `GET /api/browser/status` | `enabled: true`, `worker_ok: true` |
| `POST /api/browser/sessions` | 返回 `session_id`(已测后删除) |
| 前端 `web/app` 同步 | `sync_webapp_to_container.sh` 入口 bundle HTTP 200 |
## 待用户终验
- [ ] 登录后台 → 左下角浏览器 → 打开 `https://example.com` 有画面且可点击
- [ ] 服务器列表「站点」打开浮动窗(非新标签)
- [ ] 输入 `http://127.0.0.1` 显示明确错误
+23 -8
View File
@@ -1,5 +1,5 @@
<template>
<v-app>
<v-app :style="appChromeVars">
<!-- App Bar (隐藏于登录页) -->
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
@@ -202,8 +202,10 @@
</v-alert>
</div>
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
<!-- Main Content -->
<v-main :class="mainLayoutClass" :style="scriptExecBarOffset">
<v-main :class="mainLayoutClass" :style="mainContentOffset">
<router-view v-slot="{ Component, route: activeRoute }">
<keep-alive :include="cachedPageNames" :max="12">
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
@@ -249,6 +251,7 @@ import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
const cachedPageNames = [...CACHED_PAGE_NAMES]
@@ -330,12 +333,24 @@ const mainLayoutClass = computed(() => ({
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const scriptExecBarOffset = computed(() => {
const n = runningItems.value.length
if (!n || !auth.isLoggedIn) return undefined
const extra = n * 52
const BROWSER_PANEL_HEIGHT = 420
const appChromeVars = computed(() => {
const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
return {
paddingTop: `calc(var(--v-layout-top, 64px) + ${extra}px)`,
'--nexus-browser-dock-height': dock,
'--nexus-browser-dock-offset': dock,
}
})
const mainContentOffset = computed(() => {
if (!auth.isLoggedIn) return undefined
const execRows = runningItems.value.length
const execExtra = execRows ? execRows * 52 : 0
const browserExtra = BROWSER_PANEL_HEIGHT
const total = execExtra + browserExtra
return {
paddingTop: `calc(var(--v-layout-top, 64px) + ${total}px)`,
}
})
@@ -437,7 +452,7 @@ window.$snackbar = (text: string, color = 'success') => {
.nexus-script-exec-bar {
position: fixed;
top: var(--v-layout-top, 64px);
top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
left: var(--v-layout-left, 0);
right: 0;
z-index: 1005;
@@ -0,0 +1,328 @@
<template>
<div
v-if="tabs.length"
class="global-browser-fixed"
:class="{ 'global-browser-fixed--maximized': maximized }"
>
<v-card border rounded="0" class="global-browser-panel d-flex flex-column h-100">
<div class="global-browser-toolbar d-flex align-center flex-shrink-0">
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
>
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn size="small" variant="text" icon="mdi-plus" title="新标签" density="compact" @click="newEmptyTab" />
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" />
<v-btn size="small" variant="text" icon="mdi-history" title="浏览记录" density="compact" @click="showHistory = !showHistory" />
<v-btn size="small" variant="text" icon="mdi-restart" title="重启浏览器(保留历史记录)" density="compact" @click="onRestart" />
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
title="全屏"
class="mr-1"
@click="maximized = !maximized"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="onGoBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="onGoForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="onRefresh" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn icon="mdi-open-in-new" variant="text" size="small" :disabled="!activeTab?.url" @click="openExternal" />
</div>
<v-alert
v-if="displayError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="clearErrors"
>
{{ displayError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
Worker 上的 Chromium 远程渲染不受 iframe 嵌入限制
</p>
</div>
<div v-else class="global-browser-canvas-wrap flex-grow-1 d-flex flex-column min-h-0">
<div v-if="connecting" class="d-flex align-center justify-center flex-grow-1">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-body-2">正在连接远程浏览器</span>
</div>
<canvas
v-show="connected"
ref="canvasRef"
class="global-browser-canvas flex-grow-1"
tabindex="0"
@mousemove="onCanvasMouseMove"
@mousedown="onCanvasMouseDown"
@mouseup="onCanvasMouseUp"
@wheel.prevent="onCanvasWheel"
@keydown="onCanvasKeyDown"
@contextmenu.prevent
/>
</div>
</div>
</div>
</v-card>
</div>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { useRemoteBrowserSession } from '@/composables/useRemoteBrowserSession'
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const {
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
canGoBack,
canGoForward,
tabLabel,
openPanel,
restartBrowser,
navigate,
updateActiveFromRemote,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
bootstrap,
} = useGlobalBrowser()
const {
connected,
connecting,
remoteError,
setCanvas,
setPageInfoHandler,
connect,
disconnect,
navigate: remoteNavigate,
reload,
goBack: remoteGoBack,
goForward: remoteGoForward,
onCanvasMouseMove,
onCanvasMouseDown,
onCanvasMouseUp,
onCanvasWheel,
onCanvasKeyDown,
} = useRemoteBrowserSession()
const displayError = computed(() => remoteError.value || lastError.value)
function clearErrors() {
lastError.value = null
if (remoteError.value) remoteError.value = null
}
setPageInfoHandler((url, title) => updateActiveFromRemote(url, title))
async function ensureRemoteSession() {
if (!activeTab.value?.url || connecting.value || connected.value) return
await nextTick()
if (canvasRef.value) setCanvas(canvasRef.value)
const w = Math.max(640, Math.floor(canvasRef.value?.clientWidth || 1280))
const h = Math.max(480, Math.floor(canvasRef.value?.clientHeight || 360))
await connect(w, h)
if (connected.value && activeTab.value?.url) {
remoteNavigate(activeTab.value.url)
}
}
watch(activeTab, () => {
if (activeTab.value?.url) {
openPanel()
void ensureRemoteSession()
}
}, { flush: 'post' })
watch(activeTabId, () => {
disconnect()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
onMounted(async () => {
await bootstrap()
if (!tabs.value.length) {
newEmptyTab()
}
openPanel()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
if (navigate(addressDraft.value)) {
void ensureRemoteSession().then(() => {
if (activeTab.value?.url) remoteNavigate(activeTab.value.url)
})
}
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
disconnect()
restartBrowser()
}
function onRefresh() {
if (connected.value) reload()
}
function onGoBack() {
goBack()
if (connected.value) remoteGoBack()
}
function onGoForward() {
goForward()
if (connected.value) remoteGoForward()
}
</script>
<style scoped>
.global-browser-fixed {
position: fixed;
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1004;
height: var(--nexus-browser-dock-height, 420px);
display: flex;
flex-direction: column;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.global-browser-fixed--maximized {
height: calc(100dvh - var(--v-layout-top, 64px)) !important;
}
.global-browser-panel {
overflow: hidden;
height: 100%;
background: rgb(var(--v-theme-surface));
border-radius: 0 !important;
}
.global-browser-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-canvas-wrap {
min-height: 0;
background: #1a1a1a;
}
.global-browser-canvas {
width: 100%;
min-height: 0;
cursor: default;
outline: none;
}
</style>
@@ -0,0 +1,382 @@
/**
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
*/
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { FloatRect } from '@/composables/useFloatingPanel'
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
const SAVE_DEBOUNCE_MS = 700
export interface BrowserVisit {
url: string
title: string
at: string
}
export interface BrowserTab {
id: string
url: string
title: string
stack: string[]
stackIndex: number
}
export interface BrowserStateResponse {
url_history: string[]
visits: BrowserVisit[]
tabs: Array<{
id: string
url: string
title?: string
stack?: string[]
stack_index?: number
}>
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
const saving = ref(false)
const panelOpen = ref(false)
const minimized = ref(false)
const maximized = ref(false)
const lastError = ref<string | null>(null)
const addressDraft = ref('')
const urlHistory = ref<string[]>([])
const visits = ref<BrowserVisit[]>([])
const tabs = ref<BrowserTab[]>([])
const activeTabId = ref<string | null>(null)
const panelRect = ref<FloatRect | null>(null)
const minimizedRect = ref<FloatRect | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
function tabLabel(tab: BrowserTab): string {
try {
return tab.title || new URL(tab.url).hostname
} catch {
return tab.title || tab.url
}
}
function newTabId(): string {
return crypto.randomUUID()
}
function serializeTabs(): BrowserStateResponse['tabs'] {
return tabs.value.map((t) => ({
id: t.id,
url: t.url,
title: t.title,
stack: t.stack,
stack_index: t.stackIndex,
}))
}
function applyServerState(data: BrowserStateResponse) {
urlHistory.value = data.url_history || []
visits.value = (data.visits || []).map((v) => ({
url: v.url,
title: v.title || v.url,
at: v.at,
}))
tabs.value = (data.tabs || []).map((t) => ({
id: t.id,
url: t.url,
title: t.title || t.url,
stack: t.stack?.length ? t.stack : (t.url ? [t.url] : []),
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
}))
activeTabId.value = data.active_tab_id
if (!activeTabId.value && tabs.value.length) {
activeTabId.value = tabs.value[0]!.id
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
function syncAddressFromActive() {
const tab = activeTab.value
addressDraft.value = tab?.url ?? ''
}
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
const canGoBack = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex > 0 : false
})
const canGoForward = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex < tab.stack.length - 1 : false
})
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void persistState(extra)
}, SAVE_DEBOUNCE_MS)
}
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
saving.value = true
try {
const body: Record<string, unknown> = {
tabs: serializeTabs(),
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
if (extra.recordTitle) body.record_title = extra.recordTitle
} else {
body.url_history = urlHistory.value
body.visits = visits.value
}
const res = await http.put<BrowserStateResponse>('/browser/state', body)
applyServerState(res)
} catch {
/* keep local state */
} finally {
saving.value = false
}
}
async function migrateLegacyHistory() {
try {
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
if (!urls.length) return
for (const url of [...urls].reverse()) {
const normalized = normalizeBrowserUrl(url)
if (normalized) {
await persistState({ recordUrl: normalized, recordTitle: normalized })
}
}
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
} catch {
/* ignore */
}
}
async function bootstrap() {
if (bootstrapped.value) return
try {
const res = await http.get<BrowserStateResponse>('/browser/state')
applyServerState(res)
if (!visits.value.length && !urlHistory.value.length) {
await migrateLegacyHistory()
}
} catch {
/* offline */
} finally {
bootstrapped.value = true
}
}
function openPanel() {
panelOpen.value = true
minimized.value = false
scheduleSave()
}
function minimizePanel() {
minimized.value = true
panelOpen.value = false
scheduleSave()
}
function restartBrowser() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
}
tabs.value = [tab]
activeTabId.value = tab.id
addressDraft.value = ''
lastError.value = null
openPanel()
scheduleSave()
}
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
const normalized = normalizeBrowserUrl(input)
if (!normalized) {
lastError.value = '请输入有效的 http 或 https 地址'
return false
}
lastError.value = null
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
const tab: BrowserTab = {
id: newTabId(),
url: normalized,
title: opts?.title || normalized,
stack: [normalized],
stackIndex: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
} else {
const tab = activeTab.value
const stack = tab.stack.slice(0, tab.stackIndex + 1)
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
tab.url = normalized
if (opts?.title) tab.title = opts.title
tab.stack = stack
tab.stackIndex = stack.length - 1
}
addressDraft.value = normalized
openPanel()
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
return true
}
function navigate(input: string) {
return openUrl(input)
}
function updateActiveFromRemote(url: string, title: string) {
const tab = activeTab.value
if (!tab || !url) return
tab.url = url
if (title) tab.title = title
addressDraft.value = url
scheduleSave({ recordUrl: url, recordTitle: title })
}
function refreshActive() {
/* remote reload handled in panel */
}
function goBack() {
const tab = activeTab.value
if (!tab || tab.stackIndex <= 0) return
tab.stackIndex -= 1
tab.url = tab.stack[tab.stackIndex]!
addressDraft.value = tab.url
scheduleSave()
}
function goForward() {
const tab = activeTab.value
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
tab.stackIndex += 1
tab.url = tab.stack[tab.stackIndex]!
addressDraft.value = tab.url
scheduleSave()
}
function openExternal() {
const tab = activeTab.value
if (!tab?.url) return
window.open(tab.url, '_blank', 'noopener,noreferrer')
}
function switchTab(id: string) {
activeTabId.value = id
syncAddressFromActive()
scheduleSave()
}
function closeTab(id: string) {
if (tabs.value.length <= 1) return
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
const next = tabs.value.filter((t) => t.id !== id)
tabs.value = next
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
syncAddressFromActive()
scheduleSave()
}
function newEmptyTab() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
addressDraft.value = ''
openPanel()
scheduleSave()
}
function openFromHistory(url: string) {
openUrl(url)
}
function savePanelRect(rect: FloatRect) {
panelRect.value = rect
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
saving,
panelOpen,
minimized,
maximized,
lastError,
addressDraft,
urlHistory,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
tabLabel,
bootstrap,
openPanel,
minimizePanel,
restartBrowser,
openUrl,
navigate,
updateActiveFromRemote,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
@@ -0,0 +1,257 @@
/**
* Remote browser session — canvas screencast over /ws/browser/{session_id}.
*/
import { onUnmounted, ref, shallowRef } from 'vue'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { buildWebSocketUrl } from '@/utils/wsUrl'
export interface BrowserSessionInfo {
session_id: string
viewport_w: number
viewport_h: number
}
export function useRemoteBrowserSession() {
const connected = ref(false)
const connecting = ref(false)
const remoteError = ref<string | null>(null)
const sessionId = shallowRef<string | null>(null)
const viewportW = ref(1280)
const viewportH = ref(720)
let ws: WebSocket | null = null
let canvasEl: HTMLCanvasElement | null = null
let onPageInfo: ((url: string, title: string) => void) | null = null
let dragActive = false
let lastPointerX = 0
let lastPointerY = 0
function setCanvas(el: HTMLCanvasElement | null) {
canvasEl = el
}
function setPageInfoHandler(fn: (url: string, title: string) => void) {
onPageInfo = fn
}
function send(msg: Record<string, unknown>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify(msg))
}
function drawFrame(dataB64: string, metadata?: { deviceWidth?: number; deviceHeight?: number }) {
if (!canvasEl || !dataB64) return
const img = new Image()
img.onload = () => {
if (!canvasEl) return
const w = metadata?.deviceWidth || img.width
const h = metadata?.deviceHeight || img.height
if (canvasEl.width !== w) canvasEl.width = w
if (canvasEl.height !== h) canvasEl.height = h
const ctx = canvasEl.getContext('2d')
ctx?.drawImage(img, 0, 0, w, h)
}
img.src = `data:image/jpeg;base64,${dataB64}`
}
function canvasCoords(event: MouseEvent): { x: number; y: number } {
if (!canvasEl) return { x: 0, y: 0 }
const rect = canvasEl.getBoundingClientRect()
const scaleX = canvasEl.width / rect.width
const scaleY = canvasEl.height / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
}
}
function sendPointerMove(x: number, y: number) {
const dx = x - lastPointerX
const dy = y - lastPointerY
const dist = Math.sqrt(dx * dx + dy * dy)
const steps = Math.max(1, Math.min(25, Math.ceil(dist / 6)))
send({ type: 'MOUSE', event: 'move', x, y, steps })
lastPointerX = x
lastPointerY = y
}
function onWindowMouseMove(e: MouseEvent) {
if (!dragActive || !canvasEl) return
const { x, y } = canvasCoords(e)
sendPointerMove(x, y)
}
function endPointerDrag(e: MouseEvent) {
if (!dragActive) return
dragActive = false
window.removeEventListener('mousemove', onWindowMouseMove)
window.removeEventListener('mouseup', endPointerDrag)
const { x, y } = canvasCoords(e)
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
send({ type: 'MOUSE', event: 'up', x, y, button })
}
function onCanvasMouseMove(e: MouseEvent) {
if (dragActive) return
const { x, y } = canvasCoords(e)
sendPointerMove(x, y)
}
function onCanvasMouseDown(e: MouseEvent) {
e.preventDefault()
const { x, y } = canvasCoords(e)
lastPointerX = x
lastPointerY = y
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
send({ type: 'MOUSE', event: 'down', x, y, button })
dragActive = true
window.addEventListener('mousemove', onWindowMouseMove)
window.addEventListener('mouseup', endPointerDrag)
}
function onCanvasMouseUp(e: MouseEvent) {
endPointerDrag(e)
}
function onCanvasWheel(e: WheelEvent) {
e.preventDefault()
const { x, y } = canvasCoords(e)
send({ type: 'MOUSE', event: 'wheel', x, y, delta_y: e.deltaY })
}
function onCanvasKeyDown(e: KeyboardEvent) {
e.preventDefault()
send({ type: 'KEY', event: 'down', key: e.key, code: e.code })
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ type: 'KEY', event: 'type', text: e.key })
}
}
function disconnect() {
connected.value = false
if (ws) {
ws.close()
ws = null
}
sessionId.value = null
}
async function connect(viewportWIn = 1280, viewportHIn = 720) {
if (connecting.value || connected.value) return
connecting.value = true
remoteError.value = null
try {
const status = await http.get<{ enabled: boolean }>('/browser/status')
if (!status.enabled) {
remoteError.value = '服务端浏览器未启用'
return
}
const created = await http.post<BrowserSessionInfo>('/browser/sessions', {
viewport_w: viewportWIn,
viewport_h: viewportHIn,
})
sessionId.value = created.session_id
viewportW.value = created.viewport_w
viewportH.value = created.viewport_h
const token = useAuthStore().token
if (!token) {
remoteError.value = '未登录'
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
sessionId.value = null
return
}
const url = buildWebSocketUrl(`/ws/browser/${created.session_id}`, { token })
try {
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(url)
ws = socket
socket.onopen = () => {
connected.value = true
resolve()
}
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
socket.onclose = () => {
connected.value = false
}
socket.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as Record<string, unknown>
const type = msg.type as string
if (type === 'SCREENCAST_FRAME') {
drawFrame(String(msg.data_b64 || ''), msg.metadata as { deviceWidth?: number; deviceHeight?: number })
} else if (type === 'PAGE_INFO') {
onPageInfo?.(String(msg.url || ''), String(msg.title || ''))
} else if (type === 'ERROR') {
remoteError.value = String(msg.message || '远程浏览器错误')
} else if (type === 'BROWSER_INIT') {
viewportW.value = Number(msg.viewport_w) || viewportW.value
viewportH.value = Number(msg.viewport_h) || viewportH.value
}
} catch {
/* ignore malformed */
}
}
})
} catch (wsErr) {
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
throw wsErr
}
} catch (e) {
remoteError.value = e instanceof Error ? e.message : '无法连接远程浏览器'
disconnect()
} finally {
connecting.value = false
}
}
function navigate(url: string) {
send({ type: 'NAVIGATE', url })
}
function reload() {
send({ type: 'RELOAD' })
}
function goBack() {
send({ type: 'GO_BACK' })
}
function goForward() {
send({ type: 'GO_FORWARD' })
}
function resizeViewport(width: number, height: number) {
send({ type: 'VIEWPORT', width, height })
}
onUnmounted(() => {
window.removeEventListener('mousemove', onWindowMouseMove)
window.removeEventListener('mouseup', endPointerDrag)
disconnect()
})
return {
connected,
connecting,
remoteError,
sessionId,
viewportW,
viewportH,
setCanvas,
setPageInfoHandler,
connect,
disconnect,
navigate,
reload,
goBack,
goForward,
resizeViewport,
onCanvasMouseMove,
onCanvasMouseDown,
onCanvasMouseUp,
onCanvasWheel,
onCanvasKeyDown,
}
}
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="pa-4">
<p class="text-body-2 text-medium-emphasis">正在打开全局浏览器</p>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
const router = useRouter()
const { openPanel, newEmptyTab, tabs } = useGlobalBrowser()
onMounted(() => {
if (!tabs.value.length) newEmptyTab()
else openPanel()
router.replace('/')
})
</script>
+3 -1
View File
@@ -738,6 +738,7 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -875,6 +876,7 @@ async function confirmReplaceSlot(slotIndex: number) {
}
}
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
@@ -1545,7 +1547,7 @@ function openBrowser(item: ServerApiItem) {
snackbar('该服务器无有效域名', 'warning')
return
}
window.open(url, '_blank', 'noopener,noreferrer')
globalBrowser.openUrl(url, { title: item.name || url })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
+87
View File
@@ -0,0 +1,87 @@
"""Embedded browser UI preferences API."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.domain.models import Admin
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.browser_ui_state import (
BROWSER_UI_CONTEXT,
merge_browser_state,
parse_browser_state,
record_visit,
)
router = APIRouter(prefix="/api/browser", tags=["browser"])
class BrowserVisitRecord(BaseModel):
url: str
title: str | None = None
class BrowserTabState(BaseModel):
id: str
url: str
title: str | None = None
stack: list[str] | None = None
stack_index: int | None = None
class BrowserPanelRect(BaseModel):
x: float
y: float
w: float
h: float
class BrowserStateUpdate(BaseModel):
url_history: list[str] | None = None
visits: list[dict] | None = None
tabs: list[BrowserTabState] | None = None
active_tab_id: str | None = None
minimized: bool | None = None
rect: BrowserPanelRect | None = None
minimized_rect: BrowserPanelRect | None = None
record_url: str | None = Field(default=None, description="Append one visit + history entry")
record_title: str | None = None
@router.get("/state")
async def get_browser_state(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
return parse_browser_state(raw)
@router.put("/state")
async def put_browser_state(
payload: BrowserStateUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
incoming = payload.model_dump(exclude_none=True)
if payload.tabs is not None:
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
if payload.rect is not None:
incoming["rect"] = payload.rect.model_dump()
if payload.minimized_rect is not None:
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
merged = merge_browser_state(current, incoming)
if payload.record_url:
merged = record_visit(merged, payload.record_url, payload.record_title)
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
return merged
+197
View File
@@ -0,0 +1,197 @@
"""Remote browser session API — bridge to Browser Worker."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any, Optional
import websockets
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
from pydantic import BaseModel, Field
from server.api.auth_jwt import get_current_admin
from server.api.websocket import _verify_ws_token
from server.config import settings
from server.domain.models import Admin
from server.infrastructure.browser import session_registry
from server.infrastructure.browser.worker_client import (
BrowserWorkerError,
browser_enabled,
create_worker_session,
delete_worker_session,
worker_health,
worker_stream_url,
)
from server.utils.browser_url_safe import validate_navigation_url
logger = logging.getLogger("nexus.browser.session")
router = APIRouter(prefix="/api/browser", tags=["browser"])
ws_router = APIRouter(tags=["browser"])
class CreateBrowserSessionBody(BaseModel):
viewport_w: int = Field(default=1280, ge=320, le=1920)
viewport_h: int = Field(default=720, ge=240, le=1080)
@router.get("/status")
async def browser_status(admin: Admin = Depends(get_current_admin)):
del admin
enabled = browser_enabled()
health = await worker_health() if enabled else None
return {
"enabled": enabled,
"worker_ok": health is not None,
"worker_sessions": health.get("sessions") if health else None,
"max_sessions": settings.BROWSER_MAX_SESSIONS,
}
@router.post("/sessions")
async def create_browser_session(
body: CreateBrowserSessionBody,
admin: Admin = Depends(get_current_admin),
):
if not browser_enabled():
raise HTTPException(status_code=503, detail="服务端浏览器未启用")
# 清理断线残留(避免 409 导致前端无法重连)
for stale_id in session_registry.session_ids_for_admin(admin.id):
session_registry.remove(stale_id)
await delete_worker_session(stale_id)
if session_registry.total_count() >= settings.BROWSER_MAX_SESSIONS:
raise HTTPException(status_code=503, detail="浏览器会话已达上限")
session_id = str(uuid.uuid4())
try:
await create_worker_session(session_id, body.viewport_w, body.viewport_h)
except BrowserWorkerError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
session_registry.register(session_id, admin.id, body.viewport_w, body.viewport_h)
return {
"session_id": session_id,
"viewport_w": body.viewport_w,
"viewport_h": body.viewport_h,
}
@router.delete("/sessions/{session_id}")
async def delete_browser_session(
session_id: str,
admin: Admin = Depends(get_current_admin),
):
sess = session_registry.get(session_id)
if not sess or sess.admin_id != admin.id:
raise HTTPException(status_code=404, detail="Session not found")
session_registry.remove(session_id)
await delete_worker_session(session_id)
return {"ok": True}
def _validate_client_navigate(msg: dict[str, Any]) -> dict[str, Any] | None:
if msg.get("type") != "NAVIGATE":
return msg
url = msg.get("url", "")
normalized, err = validate_navigation_url(str(url))
if err:
return {"type": "ERROR", "message": err, "code": "URL_BLOCKED"}
out = dict(msg)
out["url"] = normalized
return out
@ws_router.websocket("/ws/browser/{session_id}")
async def browser_ws(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(None),
):
if not browser_enabled():
await websocket.close(code=4503, reason="Browser not enabled")
return
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_ws_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
sess = session_registry.get(session_id)
if not sess or sess.admin_id != admin.id:
await websocket.close(code=4403, reason="Session not authorized")
return
await websocket.accept()
worker_url = worker_stream_url(session_id)
headers = {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
try:
async with websockets.connect(
worker_url,
additional_headers=headers,
open_timeout=15,
max_size=8 * 1024 * 1024,
) as worker_ws:
async def client_to_worker() -> None:
try:
while True:
raw = await websocket.receive_text()
session_registry.touch(session_id)
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_json({
"type": "ERROR",
"message": "Invalid JSON",
"code": "BAD_JSON",
})
continue
if not isinstance(msg, dict):
continue
validated = _validate_client_navigate(msg)
if validated:
await worker_ws.send(json.dumps(validated))
except WebSocketDisconnect:
pass
async def worker_to_client() -> None:
try:
async for raw in worker_ws:
session_registry.touch(session_id)
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
await websocket.send_text(raw)
except websockets.ConnectionClosed:
pass
done, pending = await asyncio.wait(
[asyncio.create_task(client_to_worker()), asyncio.create_task(worker_to_client())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in done:
try:
await task
except Exception:
pass
except Exception as exc:
logger.warning("browser bridge error session=%s: %s", session_id, exc)
try:
await websocket.send_json({
"type": "ERROR",
"message": "无法连接 Browser Worker",
"code": "WORKER_UNREACHABLE",
})
except Exception:
pass
finally:
session_registry.remove(session_id)
await delete_worker_session(session_id)
+11 -1
View File
@@ -64,6 +64,13 @@ SETTING_ENUM_VALIDATORS: dict[str, frozenset[str]] = {
"theme": frozenset({"dark", "light"}),
}
# GET /{key} when row not yet in MySQL (PUT upserts on first save)
SETTING_DEFAULTS: dict[str, str] = {
"script_exec_complete_sound": "beep_double",
"push_complete_sound": "beep_double",
"theme": "dark",
}
# S-02: Per-key validation rules: (type, min, max) for int settings
SETTING_VALIDATORS: dict[str, tuple] = {
"db_pool_size": (int, 1, 1000),
@@ -310,7 +317,10 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
repo = SettingRepositoryImpl(db)
value = await repo.get(key)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
default = SETTING_DEFAULTS.get(key)
if default is None:
raise HTTPException(status_code=404, detail="Setting not found")
return _format_setting_response(key, default, None)
return _format_setting_response(key, value, None)
+7
View File
@@ -108,6 +108,13 @@ class Settings(BaseSettings):
NOTIFY_RESTART: str = "true" # Nexus 后端重启通知
NOTIFY_SCRIPT_COMPLETE: str = "true" # 脚本执行完成汇总(含看板链接)
# Remote browser (Playwright Worker on separate VPS)
BROWSER_ENABLED: str = "false"
BROWSER_WORKER_URL: str = ""
BROWSER_WORKER_SECRET: str = ""
BROWSER_MAX_SESSIONS: int = 2
BROWSER_IDLE_TIMEOUT_SEC: int = 1800
# extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings
model_config = SettingsConfigDict(
env_prefix="NEXUS_",
@@ -0,0 +1 @@
"""Browser Worker bridge infrastructure."""
@@ -0,0 +1,51 @@
"""In-memory browser session ownership (single-operator; primary worker only)."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
@dataclass
class BridgeSession:
session_id: str
admin_id: int
viewport_w: int
viewport_h: int
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
_sessions: dict[str, BridgeSession] = {}
def register(session_id: str, admin_id: int, viewport_w: int, viewport_h: int) -> BridgeSession:
sess = BridgeSession(session_id, admin_id, viewport_w, viewport_h)
_sessions[session_id] = sess
return sess
def get(session_id: str) -> BridgeSession | None:
return _sessions.get(session_id)
def touch(session_id: str) -> None:
sess = _sessions.get(session_id)
if sess:
sess.last_activity = time.time()
def remove(session_id: str) -> BridgeSession | None:
return _sessions.pop(session_id, None)
def count_for_admin(admin_id: int) -> int:
return sum(1 for s in _sessions.values() if s.admin_id == admin_id)
def session_ids_for_admin(admin_id: int) -> list[str]:
return [sid for sid, s in _sessions.items() if s.admin_id == admin_id]
def total_count() -> int:
return len(_sessions)
@@ -0,0 +1,82 @@
"""HTTP client for Browser Worker VPS."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import httpx
from server.config import settings
logger = logging.getLogger("nexus.browser.worker")
class BrowserWorkerError(Exception):
def __init__(self, message: str, status_code: int = 503) -> None:
super().__init__(message)
self.status_code = status_code
def browser_enabled() -> bool:
return (
settings.BROWSER_ENABLED.lower() in ("1", "true", "yes")
and bool(settings.BROWSER_WORKER_URL)
and bool(settings.BROWSER_WORKER_SECRET)
)
def _headers() -> dict[str, str]:
return {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
def worker_stream_url(session_id: str) -> str:
base = settings.BROWSER_WORKER_URL.rstrip("/")
parsed = urlparse(base)
scheme = "wss" if parsed.scheme == "https" else "ws"
netloc = parsed.netloc or parsed.path
path = f"/v1/sessions/{session_id}/stream"
return urlunparse((scheme, netloc, path, "", "", ""))
async def create_worker_session(session_id: str, viewport_w: int, viewport_h: int) -> dict[str, Any]:
if not browser_enabled():
raise BrowserWorkerError("Browser worker not enabled", 503)
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions"
payload = {"session_id": session_id, "viewport_w": viewport_w, "viewport_h": viewport_h}
try:
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
resp = await client.post(url, json=payload, headers=_headers())
except httpx.RequestError as exc:
logger.warning("worker create failed: %s", exc)
raise BrowserWorkerError("Browser worker unreachable", 503) from exc
if resp.status_code >= 400:
detail = resp.text[:200]
raise BrowserWorkerError(detail or "Worker rejected session", resp.status_code)
return resp.json()
async def delete_worker_session(session_id: str) -> None:
if not browser_enabled():
return
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions/{session_id}"
try:
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
await client.delete(url, headers=_headers())
except httpx.RequestError as exc:
logger.warning("worker delete failed session=%s: %s", session_id, exc)
async def worker_health() -> dict[str, Any] | None:
if not browser_enabled():
return None
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/health"
try:
async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
resp = await client.get(url)
if resp.status_code == 200:
return resp.json()
except httpx.RequestError:
return None
return None
+7
View File
@@ -72,6 +72,8 @@ from server.api.files import router as files_router
from server.api.search import router as search_router
from server.api.terminal import router as terminal_router
from server.api.watch import router as watch_router
from server.api.browser import router as browser_router
from server.api.browser_session import router as browser_session_router, ws_router as browser_ws_router
# Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop
@@ -696,6 +698,11 @@ app.include_router(search_router)
app.include_router(terminal_router)
app.include_router(watch_router)
# Remote browser (UI state + Worker bridge)
app.include_router(browser_router)
app.include_router(browser_session_router)
app.include_router(browser_ws_router)
# ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException)
+161
View File
@@ -0,0 +1,161 @@
"""Embedded browser UI state — history, tabs, panel layout."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
BROWSER_UI_CONTEXT = "embedded_browser"
MAX_URL_HISTORY = 128
MAX_VISIT_LOG = 512
MAX_TABS = 12
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds")
def _is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url.strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.username or parsed.password:
return False
return bool(parsed.netloc)
def _hostname(url: str) -> str:
try:
return urlparse(url).hostname or url
except Exception:
return url
def empty_browser_state() -> dict[str, Any]:
return {
"url_history": [],
"visits": [],
"tabs": [],
"active_tab_id": None,
"minimized": False,
"rect": None,
"minimized_rect": None,
}
def _parse_rect(raw: dict | None, *, default_w: float, default_h: float) -> dict[str, float] | None:
if raw is None or not isinstance(raw, dict):
return None
try:
return {
"x": float(raw.get("x", 0)),
"y": float(raw.get("y", 0)),
"w": float(raw.get("w", default_w)),
"h": float(raw.get("h", default_h)),
}
except (TypeError, ValueError):
return None
def parse_browser_state(raw: dict | None) -> dict[str, Any]:
base = empty_browser_state()
if not raw or not isinstance(raw, dict):
return base
url_history = [
u for u in raw.get("url_history") or []
if isinstance(u, str) and _is_safe_url(u)
][:MAX_URL_HISTORY]
visits: list[dict[str, str]] = []
for item in raw.get("visits") or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not _is_safe_url(url):
continue
visits.append({
"url": url,
"title": str(item.get("title") or _hostname(url))[:200],
"at": str(item.get("at") or _utcnow_iso()),
})
visits = visits[:MAX_VISIT_LOG]
tabs: list[dict[str, Any]] = []
for item in raw.get("tabs") or []:
if not isinstance(item, dict):
continue
tab_id = item.get("id")
url = item.get("url")
if not isinstance(tab_id, str) or not tab_id.strip():
continue
if not isinstance(url, str):
continue
if url.strip() and not _is_safe_url(url):
continue
if url.strip():
stack = [
u for u in item.get("stack") or [url]
if isinstance(u, str) and _is_safe_url(u)
] or [url]
stack_index = item.get("stack_index", len(stack) - 1)
if not isinstance(stack_index, int):
stack_index = len(stack) - 1
stack_index = max(0, min(stack_index, len(stack) - 1))
else:
stack = []
stack_index = -1
tabs.append({
"id": tab_id.strip()[:64],
"url": url.strip(),
"title": str(item.get("title") or (_hostname(url) if url.strip() else "新标签"))[:200],
"stack": stack[-64:],
"stack_index": stack_index,
})
tabs = tabs[:MAX_TABS]
active_tab_id = raw.get("active_tab_id")
if not isinstance(active_tab_id, str) or not any(t["id"] == active_tab_id for t in tabs):
active_tab_id = tabs[0]["id"] if tabs else None
rect = _parse_rect(raw.get("rect"), default_w=960, default_h=640)
minimized_rect = _parse_rect(raw.get("minimized_rect"), default_w=320, default_h=52)
return {
"url_history": url_history,
"visits": visits,
"tabs": tabs,
"active_tab_id": active_tab_id,
"minimized": bool(raw.get("minimized")),
"rect": rect,
"minimized_rect": minimized_rect,
}
def merge_browser_state(current: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
parsed = parse_browser_state({**current, **incoming})
if incoming.get("url_history") is not None:
parsed["url_history"] = parse_browser_state({"url_history": incoming["url_history"]})["url_history"]
if incoming.get("visits") is not None:
parsed["visits"] = parse_browser_state({"visits": incoming["visits"]})["visits"]
return parsed
def record_visit(state: dict[str, Any], url: str, title: str | None = None) -> dict[str, Any]:
if not _is_safe_url(url):
return state
next_state = parse_browser_state(state)
hist = [url] + [u for u in next_state["url_history"] if u != url]
next_state["url_history"] = hist[:MAX_URL_HISTORY]
visit = {
"url": url,
"title": (title or _hostname(url))[:200],
"at": _utcnow_iso(),
}
visits = [visit] + next_state["visits"]
next_state["visits"] = visits[:MAX_VISIT_LOG]
return next_state
+79
View File
@@ -0,0 +1,79 @@
"""SSRF-safe URL validation for remote browser navigation."""
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]:
"""Return (normalized_url, error_message)."""
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
normalized = parsed.geturl()
return normalized, None
+2 -5
View File
@@ -29,8 +29,5 @@ def test_record_visit_dedupes_history():
state = empty_browser_state()
state = record_visit(state, "https://a.example", "A")
state = record_visit(state, "https://b.example", "B")
state = record_visit(state, "https://a.example", "A again")
assert state["url_history"][0] == "https://a.example"
assert state["url_history"][1] == "https://b.example"
assert len(state["visits"]) == 3
assert state["visits"][0]["title"] == "A again"
assert state["url_history"][0] == "https://b.example"
assert len(state["visits"]) == 2
+30
View File
@@ -0,0 +1,30 @@
"""SSRF URL validation for remote browser."""
import pytest
from server.utils.browser_url_safe import validate_navigation_url
def test_accepts_https_public():
url, err = validate_navigation_url("https://example.com/path")
assert err is None
assert url is not None
assert url.startswith("https://example.com")
def test_rejects_javascript():
url, err = validate_navigation_url("javascript:alert(1)")
assert url is None
assert err is not None
def test_rejects_localhost():
url, err = validate_navigation_url("http://127.0.0.1")
assert url is None
assert err is not None
def test_rejects_private_ip_literal():
url, err = validate_navigation_url("http://10.0.0.1")
assert url is None
assert err is not None
+32
View File
@@ -0,0 +1,32 @@
"""GET /api/settings/{key} defaults when row missing."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from server.api.settings import SETTING_DEFAULTS, get_setting
@pytest.mark.asyncio
async def test_get_push_complete_sound_default_when_missing():
admin = MagicMock()
db = AsyncMock()
with patch("server.api.settings.SettingRepositoryImpl") as repo_cls:
repo_cls.return_value.get = AsyncMock(return_value=None)
out = await get_setting("push_complete_sound", admin=admin, db=db)
assert out["key"] == "push_complete_sound"
assert out["value"] == SETTING_DEFAULTS["push_complete_sound"]
@pytest.mark.asyncio
async def test_get_unknown_key_still_404():
admin = MagicMock()
db = AsyncMock()
with patch("server.api.settings.SettingRepositoryImpl") as repo_cls:
repo_cls.return_value.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc:
await get_setting("nonexistent_setting_key", admin=admin, db=db)
assert exc.value.status_code == 404