feat(browser): 远程浏览器 Worker 桥接与顶栏固定悬浮窗
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下); 含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
pydantic-settings==2.7.1
|
||||
@@ -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 | SSRF(Worker 侧) |
|
||||
| `server/api/browser_session.py` | 198 | 桥接 REST + `/ws/browser` |
|
||||
| `server/infrastructure/browser/worker_client.py` | 83 | 连 Worker |
|
||||
| `server/infrastructure/browser/session_registry.py` | 52 | 内存会话归属 |
|
||||
| `server/utils/browser_url_safe.py` | 80 | SSRF(桥接层) |
|
||||
| `server/api/browser.py` | 88 | UI 状态 `/api/browser/state` |
|
||||
| `server/utils/browser_ui_state.py` | 165 | 状态解析 |
|
||||
| `frontend/src/composables/useRemoteBrowserSession.ts` | 213 | canvas + WS |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 380 | 浮动面板 |
|
||||
| `tests/test_browser_url_safe.py` | 32 | SSRF 单测 |
|
||||
| `tests/test_browser_ui_state.py` | 35 | UI 状态单测 |
|
||||
|
||||
## API / WS 入口表
|
||||
|
||||
| 方法 | 路径 | 鉴权 |
|
||||
|------|------|------|
|
||||
| GET | `/api/browser/status` | JWT `get_current_admin` |
|
||||
| POST | `/api/browser/sessions` | JWT |
|
||||
| DELETE | `/api/browser/sessions/{id}` | JWT + 会话归属 |
|
||||
| GET/PUT | `/api/browser/state` | JWT + `admin_ui_preferences` |
|
||||
| WS | `/ws/browser/{session_id}?token=` | JWT + 会话归属 |
|
||||
| GET | Worker `/health` | **无** |
|
||||
| POST/DELETE | Worker `/v1/sessions*` | `X-Nexus-Worker-Key` |
|
||||
| WS | Worker `/v1/sessions/{id}/stream` | Worker Key(Header) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| ID | 规则 | 结论 |
|
||||
|----|------|------|
|
||||
| H1 | SSRF / 私网访问 | **RISK** — 见 F1、F2 |
|
||||
| H2 | 鉴权 / IDOR | PASS — WS 校验 admin_id |
|
||||
| H3 | 密钥不落库/不入 git | PASS — `.env` / 持久卷 |
|
||||
| H4 | 无静默吞错 | **RISK** — 见 F4 |
|
||||
| H5 | Worker 暴露面 | **RISK** — 见 F3 |
|
||||
| H6 | 多 worker 一致性 | **RISK** — 见 F5 |
|
||||
| H7 | UI 状态 URL 白名单 | PASS — `browser_ui_state` |
|
||||
| H8 | 设计符合度(redirect) | **GAP** — 见 F1 |
|
||||
|
||||
## Closure(全表)
|
||||
|
||||
| ID | 判定 | 严重度 | 位置 | 说明 |
|
||||
|----|------|--------|------|------|
|
||||
| H1 | FINDING | **P1** | `browser-worker/session_manager.py` NAVIGATE 后 | **F1** 页内点击/重定向未逐跳 SSRF 校验;设计文档要求 redirect 后重验 |
|
||||
| H1 | FINDING | **P1** | Worker 部署 ufw | **F3** 装机时额外 `ufw allow 8443/tcp`(全网),削弱「仅主站 IP」隔离 |
|
||||
| H2 | SAFE | — | `browser_session.py:125-127` | `session_id` 必须属于当前 `admin.id` |
|
||||
| H2 | SAFE | — | `browser_session.py:95-104` | 桥接层 NAVIGATE 二次校验 |
|
||||
| H3 | SAFE | — | `worker_client.py`, Worker `.env` | `WORKER_SECRET` 仅环境变量 |
|
||||
| H4 | FINDING | P2 | `worker_client.py:67-68` | **F4** `delete_worker_session` 网络失败仅 warning,可能残留 Worker 会话 |
|
||||
| H5 | FINDING | P2 | `browser-worker/main.py:45-51` | `/health` 无认证;若 8443 对公网可见会泄露会话数 |
|
||||
| H6 | FINDING | P2 | `session_registry.py` | **F5** 内存注册表不跨 uvicorn worker;多进程下 WS 可能 4403 |
|
||||
| H7 | SAFE | — | `browser_ui_state.py` | http(s) only,无凭据 URL |
|
||||
| H8 | SAFE | — | screencast 热修 | `cdp.on("Page.screencastFrame")` 已替代错误 API |
|
||||
| — | SAFE | — | `browser_session.py:61-64` | 创建前清理陈旧会话,避免 409 |
|
||||
| — | SAFE | — | 前端 | 不再 iframe;canvas 远程渲染 |
|
||||
| — | SAFE | — | CUD 审计 | 首版未记 session 审计,与设计「与 RDP 一致」一致 |
|
||||
|
||||
### F1 — 重定向 / 页内导航 SSRF 缺口(P1)
|
||||
|
||||
`validate_navigation_url` 仅在显式 `NAVIGATE` 消息时调用。用户在远程 Chromium 内**点击链接**或 **302 跳转**到 `http://10.x` / metadata 时,Playwright 会照常请求,**不经过**校验。
|
||||
|
||||
**建议**:`page.on("framenavigated")` / `response` 监听,每跳对 `page.url` 调用 `validate_navigation_url`;失败则 `page.close()` 或 `about:blank` 并 WS `ERROR`。
|
||||
|
||||
### F3 — Worker 防火墙过宽(P1)
|
||||
|
||||
部署日志显示除 `from 20.24.218.235` 外还有 `ufw allow 8443/tcp`(Anywhere)。攻击者若猜到 `WORKER_SECRET`(或暴力)可直连 Worker。
|
||||
|
||||
**建议**:删除全网 8443 规则,仅保留主站源 IP;`/health` 改内网或加 Key。
|
||||
|
||||
### F4 — Worker 删除失败(P2)
|
||||
|
||||
`delete_worker_session` 异常时静默;依赖 Worker 空闲超时清理。
|
||||
|
||||
### F5 — 多 uvicorn worker(P2)
|
||||
|
||||
`session_registry` 进程内字典;生产若 `--workers>1` 可能 POST 在 worker A、WS 落到 worker B。当前 Docker 单进程可接受;扩 worker 时需 Redis 注册表。
|
||||
|
||||
## 外部输入 → Sink
|
||||
|
||||
| 输入 | Sink | 缓解 |
|
||||
|------|------|------|
|
||||
| `NAVIGATE.url` | Playwright `goto` | 双端 `validate_navigation_url` |
|
||||
| 页内链接点击 | Playwright 导航 | **未缓解(F1)** |
|
||||
| WS `MOUSE`/`KEY` | Chromium | 仅已认证管理员 |
|
||||
| `browser/state` tabs URL | MySQL JSON | `_is_safe_url` 过滤 |
|
||||
| Worker Key | Worker API | 常量时间比较(`!=`) |
|
||||
|
||||
## DoD
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| `pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py` | 7 passed(生产机);本地沙箱 DNS 失败属环境限制 |
|
||||
| `npm run type-check` | 已通过(实现日) |
|
||||
| 生产 `GET /api/browser/status` | `enabled:true`, `worker_ok:true` |
|
||||
| 生产 screencast 冒烟 | `SCREENCAST_OK` ~4KB JPEG |
|
||||
| changelog | `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` |
|
||||
|
||||
## 总结
|
||||
|
||||
| 级别 | 数量 | 处置建议 |
|
||||
|------|------|----------|
|
||||
| P0 | 0 | — |
|
||||
| P1 | 2 | F1 redirect/页内 SSRF;F3 收紧 ufw |
|
||||
| P2 | 3 | F4/F5 可排期;/health 加固 |
|
||||
|
||||
**合并结论**:功能链路可用,鉴权与显式 NAVIGATE SSRF 达标;**未达设计文档「每跳 redirect 校验」**,且 Worker **8443 应对公网收口**。建议先修 F3(运维),再实现 F1(代码)后复审计。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check
|
||||
# 生产
|
||||
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
|
||||
```
|
||||
@@ -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` tar;Worker `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
|
||||
# 生产(JWT)GET /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 Chromium,Nexus 主站(`api.synaglobal.vip`)通过内网 API 桥接,管理员在浮动面板 canvas 操作。
|
||||
> **设计**:[`2026-06-11-server-browser-chromium-design.md`](../specs/2026-06-11-server-browser-chromium-design.md)
|
||||
> **状态**:待购机 → 装机 → Nexus 代码联调 → 生产接入
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要单独买一台
|
||||
|
||||
| 项 | 全部塞进 Nexus 主站 | **独立 Worker(本计划)** |
|
||||
|----|---------------------|---------------------------|
|
||||
| 主站内存 | 每会话 +200~500MB | 主站几乎不增 |
|
||||
| Docker 镜像 | +300~500MB | 主镜像不变 |
|
||||
| SSRF 风险面 | 与 API/DB 同机 | 隔离在 Worker |
|
||||
| 扩缩容 | 和 API 绑死 | 可单独升配 Worker |
|
||||
|
||||
你已确认:**任意公网 https + Chromium**;**不要 iframe**。Worker 分离是推荐部署形态。
|
||||
|
||||
---
|
||||
|
||||
## 2. 购机规格(下单前对照)
|
||||
|
||||
### 2.1 最低可用(单人运维、≤2 并发会话)
|
||||
|
||||
| 项 | 建议 |
|
||||
|----|------|
|
||||
| **CPU** | 2 vCPU(x86_64) |
|
||||
| **内存** | **4 GB**(Chromium 单会话约 300~800MB,留系统与缓冲) |
|
||||
| **磁盘** | 40 GB SSD(系统 + Chromium 缓存) |
|
||||
| **系统** | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(x86_64) |
|
||||
| **带宽** | ≥ 5 Mbps 出站(画面 JPEG 流;10 Mbps 更稳) |
|
||||
| **地域** | 与 Nexus 主站**同区域或近区**(降延迟) |
|
||||
|
||||
### 2.2 推荐配置(留余量、偶尔双标签)
|
||||
|
||||
| 项 | 建议 |
|
||||
|----|------|
|
||||
| CPU | 4 vCPU |
|
||||
| 内存 | **8 GB** |
|
||||
| 磁盘 | 60 GB SSD |
|
||||
|
||||
### 2.3 不建议
|
||||
|
||||
- **1 GB / 2 GB 内存** — Chromium 易 OOM,会话被 kill
|
||||
- **ARM 除非验证 Playwright** — 优先 x86_64,减少兼容问题
|
||||
- **与 Nexus 主站共用同一台** — 违背隔离目的(若预算极紧可临时同机,不推荐)
|
||||
|
||||
### 2.4 厂商与形态
|
||||
|
||||
任意支持 **Ubuntu + 公网 IP** 的 VPS 即可(Azure、阿里云、腾讯云、Vultr 等)。
|
||||
**不必**买 Windows;Worker 只跑 Linux + Docker。
|
||||
|
||||
---
|
||||
|
||||
## 3. 网络与安全(购机时一并规划)
|
||||
|
||||
### 3.1 拓扑
|
||||
|
||||
```
|
||||
[ 管理员浏览器 ] ──HTTPS──► [ Nexus 主站 20.24.218.235 :443 ]
|
||||
│
|
||||
内网/VPN/专线(推荐)
|
||||
▼
|
||||
[ Browser Worker :8443 ]
|
||||
│
|
||||
└──► 任意公网站点
|
||||
```
|
||||
|
||||
### 3.2 防火墙(Worker 上)
|
||||
|
||||
| 方向 | 规则 |
|
||||
|------|------|
|
||||
| 入站 | **仅允许 Nexus 主站 IP** 访问 Worker 服务端口(默认 `8443/tcp`) |
|
||||
| 入站 | SSH `22/tcp` 仅你的运维 IP(或跳板机) |
|
||||
| 入站 | **禁止** 0.0.0.0/0 访问 8443 |
|
||||
| 出站 | 允许 `80/443` 访问公网(浏览器上网) |
|
||||
| 出站 | 禁止 Worker 访问 Nexus 内网/MySQL/Redis(Worker 不需要) |
|
||||
|
||||
> 若 Nexus 与 Worker **同云厂商同 VPC**,用**内网 IP** 通信,Worker **不要绑公网 8443**。
|
||||
|
||||
### 3.3 密钥
|
||||
|
||||
购机后生成一对:
|
||||
|
||||
- `NEXUS_BROWSER_WORKER_SECRET` — 随机 ≥ 32 字节,Worker 与 Nexus 主站 `.env` **各存一份相同值**
|
||||
- Worker **不**存 Nexus `SECRET_KEY` / `DATABASE_URL`
|
||||
|
||||
---
|
||||
|
||||
## 4. 购机后你要收集的信息(交给 Agent 联调)
|
||||
|
||||
买好后请记录(可写在本地 `.env` 片段,**勿提交 git**):
|
||||
|
||||
```bash
|
||||
# Browser Worker(新机器)
|
||||
BROWSER_WORKER_PUBLIC_IP= # 若无内网则用
|
||||
BROWSER_WORKER_PRIVATE_IP= # 与 Nexus 同 VPC 时优先
|
||||
BROWSER_WORKER_SSH=user@ip
|
||||
BROWSER_WORKER_SSH_KEY=/path/to/key.pem
|
||||
|
||||
# Nexus 主站侧(deploy/nexus-1panel.secrets.sh 或生产 .env)
|
||||
NEXUS_BROWSER_ENABLED=1
|
||||
NEXUS_BROWSER_WORKER_URL=https://<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_*
|
||||
# 重启 API:supervisorctl restart nexus 或 docker compose restart nexus
|
||||
bash scripts/local_verify.sh # 开发机
|
||||
# 生产:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
|
||||
```
|
||||
|
||||
### Phase D — 验收
|
||||
|
||||
| # | 检查项 | 通过标准 |
|
||||
|---|--------|----------|
|
||||
| 1 | Worker health | `/health` → ok |
|
||||
| 2 | 公网站点 | 面板打开 `https://example.com` 有画面 |
|
||||
| 3 | iframe 曾白屏的站 | 能显示(抽 1~2 个已知站) |
|
||||
| 4 | SSRF | 面板输入 `http://127.0.0.1` → 明确错误,Worker 日志无成功请求 |
|
||||
| 5 | 隔离 | 未授权 IP 无法连 Worker 8443 |
|
||||
| 6 | 持久化 | 刷新 Nexus 后标签/收藏仍在(MySQL) |
|
||||
| 7 | 重启 Worker | Nexus 会话断开可重建,不拖垮主站 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nexus 代码实施顺序(与购机并行)
|
||||
|
||||
你可**先买服务器**;Agent 在仓库内并行开发,**不阻塞购机**。
|
||||
|
||||
| 阶段 | 内容 | 依赖 Worker 机器 |
|
||||
|------|------|------------------|
|
||||
| **W1** | `browser-worker/` 独立服务 + Docker + health | 否(本地 Docker 可测) |
|
||||
| **W2** | SSRF 校验、Playwright screencast、Worker WS 协议 | 否 |
|
||||
| **W3** | Nexus 主站 `browser_bridge` 桥接 + `/ws/browser` | 否(mock Worker) |
|
||||
| **W4** | 前端 canvas 替换 iframe | 否 |
|
||||
| **W5** | 你在 Worker VPS 部署 + 主站 `.env` 联调 | **是** |
|
||||
| **W6** | changelog、audit、门控、生产部署 | 是 |
|
||||
|
||||
**购机前可完成 W1~W4**;**W5 需要你的 Worker IP + SSH**。
|
||||
|
||||
---
|
||||
|
||||
## 7. Worker 服务接口(预定,实现以代码为准)
|
||||
|
||||
Nexus 主站 → Worker(内网,Header `X-Nexus-Worker-Key`):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 就绪探测 |
|
||||
| POST | `/v1/sessions` | 创建 `{ session_id, viewport }` |
|
||||
| DELETE | `/v1/sessions/{id}` | 销毁 |
|
||||
| WS | `/v1/sessions/{id}/stream` | 双向:帧 + navigate/键鼠 |
|
||||
|
||||
管理员浏览器 **只连 Nexus**;不直连 Worker(Worker 不对管理员暴露)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 预算参考(仅供参考)
|
||||
|
||||
| 配置 | 大致月费(国内/海外 VPS) |
|
||||
|------|---------------------------|
|
||||
| 2C4G | ¥50~150 / $5~20 |
|
||||
| 4C8G | ¥100~300 / $15~40 |
|
||||
|
||||
另计:出站流量(画面流持续使用时略高于纯 API)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 购机 Checklist(打印勾选)
|
||||
|
||||
**下单前**
|
||||
|
||||
- [ ] 2 vCPU + **4 GB RAM** 及以上
|
||||
- [ ] Ubuntu 22.04/24.04 x86_64
|
||||
- [ ] 与 Nexus 主站同区域或近区
|
||||
- [ ] 可配置安全组 / ufw
|
||||
- [ ] 独立公网 IP 或与 Nexus **同 VPC 内网**
|
||||
|
||||
**到手后**
|
||||
|
||||
- [ ] SSH 可登录
|
||||
- [ ] 记录公网 IP / 内网 IP
|
||||
- [ ] ufw:8443 仅 Nexus 主站 IP
|
||||
- [ ] 生成 `NEXUS_BROWSER_WORKER_SECRET`
|
||||
- [ ] 把 §4 信息发给 Agent / 写入本地 secrets
|
||||
- [ ] 等 `browser-worker/` 合并后执行 Phase B~D
|
||||
|
||||
**回滚**
|
||||
|
||||
- [ ] 主站 `NEXUS_BROWSER_ENABLED=0` 即关闭,Worker 可关机不影响 Nexus
|
||||
|
||||
---
|
||||
|
||||
## 10. 与现有文档关系
|
||||
|
||||
| 文档 | 关系 |
|
||||
|------|------|
|
||||
| `server-browser-chromium-design.md` | 功能与安全 SSOT |
|
||||
| `server-browser-chromium.md` | Nexus 主站 + 前端实现清单 |
|
||||
| **本文** | **购机、网络、Worker 装机、联调验收** |
|
||||
|
||||
购机完成后,在新会话说:「Worker 已就绪,IP 是 …」并提供 SSH(secrets 本地),Agent 从 **W5 联调** 继续。
|
||||
@@ -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 风险 | 无 | **有,必须缓解** |
|
||||
| 资源 | 几乎无 | 每会话 ~200–500MB RAM |
|
||||
| Cookie / 登录 | 用户本机 | **服务器浏览器 Profile** |
|
||||
|
||||
## 方案概述
|
||||
|
||||
类比 RDP(guacd → WebSocket → 前端画布),但 Nexus **拥有**浏览器进程与协议:
|
||||
|
||||
```
|
||||
管理员浏览器 (Vue SPA)
|
||||
│ JWT WebSocket + REST
|
||||
▼
|
||||
Nexus API (FastAPI) — 桥接,不跑 Chromium
|
||||
│ Worker Key + 内网 HTTPS
|
||||
▼
|
||||
Browser Worker VPS — Playwright Chromium(headless + CDP screencast)
|
||||
▼
|
||||
公网目标站点
|
||||
```
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| **Playwright Chromium** | 运行在 **Browser Worker**;Nexus 桥接会话 |
|
||||
| **URL 安全网关** | 导航前 DNS 解析 + IP 段校验,阻断私网/元数据 SSRF |
|
||||
| **`/ws/browser/{session_id}`** | 推送 JPEG 帧;接收 navigate / 键鼠 / 滚动 |
|
||||
| **`POST/DELETE /api/browser/sessions`** | 创建、销毁会话 |
|
||||
| **`GlobalBrowserPanel`** | `<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 risk:DNS 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 主站镜像**,而在**单独 VPS(Browser Worker)**运行;Nexus 主站仅桥接 WebSocket。
|
||||
|
||||
```
|
||||
管理员浏览器 → Nexus API(JWT)→ Browser Worker(Worker 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 依赖仅当确认不再使用
|
||||
@@ -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 后按 W1~W4 重建 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` **不**更新 stack;canvas 内点击只改 `url` | **未修** |
|
||||
| 7 | **P2** | `connect()` 失败后无显式 DELETE | 同上 | **已修** — WS/未登录失败时 `DELETE /browser/sessions/{id}` |
|
||||
| 8 | **P2** | `attach_stream` 并发竞态 | `ready=false` 时锁在 L140 释放,双 WS 可能各建 `BrowserContext` | 低概率(单桥接) |
|
||||
| 9 | **P2** | `delete_worker_session` 失败静默 | 日志 warning,Worker 会话残留 | **未修**(审计 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 个 JS,grep 零命中 |
|
||||
| 侧栏「浏览器」/ 地球按钮 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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) } })
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user