Initial release: cloud browser with auth and one-click deploy on port 32450

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 10:57:37 +08:00
commit 65f5caf4d9
20 changed files with 2118 additions and 0 deletions
View File
+109
View File
@@ -0,0 +1,109 @@
import json
import os
import secrets
import time
from pathlib import Path
from typing import Optional
import bcrypt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin"
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
class AuthManager:
def __init__(self, data_dir: Path) -> None:
self.data_dir = data_dir
self.auth_file = data_dir / "auth.json"
self.secret_file = data_dir / "secret.key"
self._serializer: Optional[URLSafeTimedSerializer] = None
self._ensure_data_dir()
self._ensure_secret()
self._ensure_default_user()
def _ensure_data_dir(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
def _ensure_secret(self) -> None:
if not self.secret_file.exists():
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
secret = self.secret_file.read_text(encoding="utf-8").strip()
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
def _ensure_default_user(self) -> None:
if not self.auth_file.exists():
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
def _hash_password(self, password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _verify_password(self, password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except ValueError:
return False
def _load_credentials(self) -> dict:
return json.loads(self.auth_file.read_text(encoding="utf-8"))
def _save_credentials(self, username: str, password: str) -> None:
payload = {
"username": username,
"password_hash": self._hash_password(password),
}
self.auth_file.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def get_username(self) -> str:
return self._load_credentials()["username"]
def authenticate(self, username: str, password: str) -> bool:
creds = self._load_credentials()
if username != creds["username"]:
return False
return self._verify_password(password, creds["password_hash"])
def create_token(self, username: str) -> str:
assert self._serializer is not None
return self._serializer.dumps({"username": username, "ts": time.time()})
def verify_token(self, token: str) -> Optional[str]:
if not token or self._serializer is None:
return None
try:
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
except (BadSignature, SignatureExpired):
return None
username = data.get("username")
if username != self.get_username():
return None
return username
def change_credentials(
self,
current_username: str,
current_password: str,
new_username: str,
new_password: str,
) -> None:
creds = self._load_credentials()
if current_username != creds["username"]:
raise ValueError("当前用户名不正确")
if not self._verify_password(current_password, creds["password_hash"]):
raise ValueError("当前密码不正确")
if len(new_username.strip()) < 2:
raise ValueError("新用户名至少 2 个字符")
if len(new_password) < 4:
raise ValueError("新密码至少 4 个字符")
self._save_credentials(new_username.strip(), new_password)
def get_data_dir() -> Path:
return Path(os.getenv("DATA_DIR", "/app/data"))
auth_manager = AuthManager(get_data_dir())
+267
View File
@@ -0,0 +1,267 @@
import asyncio
import base64
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
@dataclass
class BrowserSession:
session_id: str
url: str
playwright: Playwright
browser: Browser
context: BrowserContext
page: Page
cdp: Any
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
subscribers: set[asyncio.Queue] = field(default_factory=set)
screencast_task: Optional[asyncio.Task] = None
idle_task: Optional[asyncio.Task] = None
closed: bool = False
viewport_width: int = 1280
viewport_height: int = 720
class BrowserManager:
def __init__(self, max_sessions: int = 1) -> None:
self.max_sessions = max_sessions
self._sessions: dict[str, BrowserSession] = {}
self._lock = asyncio.Lock()
async def create_session(self, url: str) -> BrowserSession:
async with self._lock:
if len(self._sessions) >= self.max_sessions:
raise RuntimeError(
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
)
session_id = str(uuid.uuid4())
width, height = get_viewport_size()
quality = get_screencast_quality()
playwright = await async_playwright().start()
browser = await playwright.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
)
context = await browser.new_context(
viewport={"width": width, "height": height},
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"
),
)
page = await context.new_page()
cdp = await context.new_cdp_session(page)
session = BrowserSession(
session_id=session_id,
url=url,
playwright=playwright,
browser=browser,
context=context,
page=page,
cdp=cdp,
viewport_width=width,
viewport_height=height,
)
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
session.url = page.url
page.on(
"framenavigated",
lambda frame: asyncio.create_task(
self._on_frame_navigated(session, frame)
),
)
session.screencast_task = asyncio.create_task(
self._run_screencast(session, quality)
)
session.idle_task = asyncio.create_task(self._watch_idle(session))
self._sessions[session_id] = session
return session
def session_count(self) -> int:
return len(self._sessions)
async def get_session(self, session_id: str) -> Optional[BrowserSession]:
return self._sessions.get(session_id)
async def close_session(self, session_id: str) -> None:
async with self._lock:
session = self._sessions.pop(session_id, None)
if session:
await self._cleanup_session(session)
async def close_all(self) -> None:
async with self._lock:
session_ids = list(self._sessions.keys())
for session_id in session_ids:
await self.close_session(session_id)
def touch(self, session: BrowserSession) -> None:
session.last_activity = time.time()
async def navigate(self, session: BrowserSession, url: str) -> str:
self.touch(session)
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
session.url = session.page.url
return session.url
async def go_back(self, session: BrowserSession) -> str:
self.touch(session)
await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
session.url = session.page.url
return session.url
async def go_forward(self, session: BrowserSession) -> str:
self.touch(session)
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
session.url = session.page.url
return session.url
async def reload(self, session: BrowserSession) -> str:
self.touch(session)
await session.page.reload(wait_until="domcontentloaded", timeout=60000)
session.url = session.page.url
return session.url
def subscribe(self, session: BrowserSession) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=8)
session.subscribers.add(queue)
return queue
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
session.subscribers.discard(queue)
async def _broadcast(self, session: BrowserSession, message: dict) -> None:
dead: list[asyncio.Queue] = []
for queue in session.subscribers:
try:
queue.put_nowait(message)
except asyncio.QueueFull:
try:
queue.get_nowait()
queue.put_nowait(message)
except asyncio.QueueEmpty:
pass
except Exception:
dead.append(queue)
for queue in dead:
session.subscribers.discard(queue)
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
if session.closed or frame != session.page.main_frame:
return
session.url = session.page.url
await self._broadcast(session, {"type": "url_update", "url": session.url})
async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
async def on_screencast_frame(params: dict) -> None:
if session.closed:
return
data = params.get("data", "")
session_id = params.get("sessionId")
try:
await session.cdp.send(
"Page.screencastFrameAck", {"sessionId": session_id}
)
except Exception:
return
try:
frame_bytes = base64.b64decode(data)
except Exception:
return
await self._broadcast(
session,
{
"type": "frame",
"data": frame_bytes,
"url": session.url,
"width": session.viewport_width,
"height": session.viewport_height,
},
)
def schedule_frame(params: dict) -> None:
asyncio.create_task(on_screencast_frame(params))
session.cdp.on("Page.screencastFrame", schedule_frame)
await session.cdp.send(
"Page.startScreencast",
{
"format": "jpeg",
"quality": quality,
"maxWidth": session.viewport_width,
"maxHeight": session.viewport_height,
"everyNthFrame": 1,
},
)
try:
while not session.closed:
await asyncio.sleep(1)
finally:
try:
await session.cdp.send("Page.stopScreencast")
except Exception:
pass
async def _watch_idle(self, session: BrowserSession) -> None:
timeout = get_idle_timeout()
try:
while not session.closed:
await asyncio.sleep(30)
if time.time() - session.last_activity > timeout:
await self.close_session(session.session_id)
break
except asyncio.CancelledError:
pass
async def _cleanup_session(self, session: BrowserSession) -> None:
if session.closed:
return
session.closed = True
for task in (session.screencast_task, session.idle_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
try:
await session.context.close()
except Exception:
pass
try:
await session.browser.close()
except Exception:
pass
try:
await session.playwright.stop()
except Exception:
pass
browser_manager = BrowserManager()
+70
View File
@@ -0,0 +1,70 @@
from typing import Any
from app.browser_manager import BrowserManager, BrowserSession
async def handle_input(
manager: BrowserManager,
session: BrowserSession,
payload: dict[str, Any],
) -> dict[str, Any] | None:
action = payload.get("action")
if not action:
return None
manager.touch(session)
page = session.page
if action == "click":
x = float(payload.get("x", 0))
y = float(payload.get("y", 0))
button = payload.get("button", "left")
await page.mouse.click(x, y, button=button)
return {"type": "ack", "action": action}
if action == "dblclick":
x = float(payload.get("x", 0))
y = float(payload.get("y", 0))
await page.mouse.dblclick(x, y)
return {"type": "ack", "action": action}
if action == "mousemove":
x = float(payload.get("x", 0))
y = float(payload.get("y", 0))
await page.mouse.move(x, y)
return None
if action == "wheel":
delta_x = float(payload.get("deltaX", 0))
delta_y = float(payload.get("deltaY", 0))
await page.mouse.wheel(delta_x, delta_y)
return {"type": "ack", "action": action}
if action == "keydown":
key = payload.get("key")
if not key:
return None
await page.keyboard.down(key)
return {"type": "ack", "action": action}
if action == "keyup":
key = payload.get("key")
if not key:
return None
await page.keyboard.up(key)
return {"type": "ack", "action": action}
if action == "type":
text = payload.get("text", "")
if text:
await page.keyboard.type(text)
return {"type": "ack", "action": action}
if action == "press":
key = payload.get("key")
if not key:
return None
await page.keyboard.press(key)
return {"type": "ack", "action": action}
return {"type": "error", "message": f"未知操作: {action}"}
+306
View File
@@ -0,0 +1,306 @@
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from app.auth import auth_manager
from app.browser_manager import browser_manager
from app.input_handler import handle_input
from app.security import SecurityError, get_max_sessions, validate_url
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
SESSION_COOKIE = "cloud_browser_token"
class CreateSessionRequest(BaseModel):
url: str = Field(..., min_length=1, max_length=2048)
class CreateSessionResponse(BaseModel):
session_id: str
url: str
class NavigateRequest(BaseModel):
url: str = Field(..., min_length=1, max_length=2048)
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=128)
class ChangeCredentialsRequest(BaseModel):
current_username: str = Field(..., min_length=1, max_length=64)
current_password: str = Field(..., min_length=1, max_length=128)
new_username: str = Field(..., min_length=2, max_length=64)
new_password: str = Field(..., min_length=4, max_length=128)
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
username = auth_manager.verify_token(token or "")
if not username:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return username
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
httponly=True,
samesite="lax",
max_age=60 * 60 * 24 * 7,
path="/",
)
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
browser_manager.max_sessions = get_max_sessions()
yield
await browser_manager.close_all()
app = FastAPI(title="Cloud Browser", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/", response_class=HTMLResponse)
async def index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/view/{session_id}", response_class=HTMLResponse)
async def view_page(session_id: str):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在或已过期")
return FileResponse(STATIC_DIR / "viewer.html")
@app.get("/api/health")
async def health():
return {"status": "ok", "sessions": browser_manager.session_count}
@app.get("/api/auth/me")
async def auth_me(user: str = Depends(get_current_user)):
return {"username": user}
@app.post("/api/auth/login")
async def auth_login(body: LoginRequest):
if not auth_manager.authenticate(body.username, body.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = auth_manager.create_token(body.username)
response = JSONResponse({"username": body.username, "message": "登录成功"})
return _set_auth_cookie(response, token)
@app.post("/api/auth/logout")
async def auth_logout():
response = JSONResponse({"message": "已退出登录"})
response.delete_cookie(SESSION_COOKIE, path="/")
return response
@app.post("/api/auth/change-credentials")
async def change_credentials(
body: ChangeCredentialsRequest,
user: str = Depends(get_current_user),
):
if body.current_username != user:
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
try:
auth_manager.change_credentials(
body.current_username,
body.current_password,
body.new_username,
body.new_password,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
token = auth_manager.create_token(body.new_username)
response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
return _set_auth_cookie(response, token)
@app.post("/api/session", response_model=CreateSessionResponse)
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
try:
url = validate_url(body.url)
except SecurityError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
session = await browser_manager.create_session(url)
except RuntimeError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
return CreateSessionResponse(session_id=session.session_id, url=session.url)
@app.delete("/api/session/{session_id}")
async def delete_session(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
await browser_manager.close_session(session_id)
return {"status": "closed"}
@app.post("/api/session/{session_id}/navigate")
async def navigate_session(
session_id: str,
body: NavigateRequest,
user: str = Depends(get_current_user),
):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
try:
url = validate_url(body.url)
except SecurityError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
current_url = await browser_manager.navigate(session, url)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
return {"url": current_url}
@app.post("/api/session/{session_id}/back")
async def go_back(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
try:
url = await browser_manager.go_back(session)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
return {"url": url}
@app.post("/api/session/{session_id}/forward")
async def go_forward(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
try:
url = await browser_manager.go_forward(session)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
return {"url": url}
@app.post("/api/session/{session_id}/reload")
async def reload_page(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
try:
url = await browser_manager.reload(session)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
return {"url": url}
@app.websocket("/ws/{session_id}")
async def websocket_stream(
websocket: WebSocket,
session_id: str,
cloud_browser_token: Optional[str] = Cookie(None),
):
if not auth_manager.verify_token(cloud_browser_token or ""):
await websocket.close(code=4401, reason="未登录")
return
session = await browser_manager.get_session(session_id)
if not session:
await websocket.close(code=4404, reason="会话不存在")
return
await websocket.accept()
queue = browser_manager.subscribe(session)
await websocket.send_json(
{
"type": "init",
"url": session.url,
"width": session.viewport_width,
"height": session.viewport_height,
}
)
async def forward_frames():
while not session.closed:
try:
message = await asyncio.wait_for(queue.get(), timeout=30)
except asyncio.TimeoutError:
continue
if message.get("type") == "frame":
await websocket.send_bytes(message["data"])
if message.get("url") and message["url"] != session.url:
await websocket.send_json({"type": "url", "url": message["url"]})
elif message.get("type") == "closed":
await websocket.send_json(message)
break
forward_task = asyncio.create_task(forward_frames())
try:
while True:
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_json({"type": "error", "message": "无效 JSON"})
continue
action_type = payload.get("type", "input")
if action_type == "ping":
browser_manager.touch(session)
await websocket.send_json({"type": "pong"})
continue
if action_type == "navigate":
try:
url = validate_url(payload.get("url", ""))
current_url = await browser_manager.navigate(session, url)
await websocket.send_json({"type": "url", "url": current_url})
except SecurityError as exc:
await websocket.send_json({"type": "error", "message": str(exc)})
except Exception as exc:
await websocket.send_json(
{"type": "error", "message": f"导航失败: {exc}"}
)
continue
result = await handle_input(browser_manager, session, payload)
if result:
await websocket.send_json(result)
except WebSocketDisconnect:
pass
finally:
forward_task.cancel()
try:
await forward_task
except asyncio.CancelledError:
pass
browser_manager.unsubscribe(session, queue)
+93
View File
@@ -0,0 +1,93 @@
import ipaddress
import os
import re
from urllib.parse import urlparse
BLOCKED_HOSTNAMES = {
"localhost",
"localhost.localdomain",
"metadata.google.internal",
}
PRIVATE_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
ALLOWED_SCHEMES = {"http", "https"}
class SecurityError(ValueError):
pass
def _normalize_url(raw_url: str) -> str:
url = raw_url.strip()
if not url:
raise SecurityError("URL 不能为空")
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
url = f"https://{url}"
parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise SecurityError("仅允许 http/https 协议")
if not parsed.netloc:
raise SecurityError("URL 格式无效")
if parsed.username or parsed.password:
raise SecurityError("URL 中不允许包含用户名或密码")
hostname = parsed.hostname
if not hostname:
raise SecurityError("无法解析主机名")
hostname_lower = hostname.lower()
if hostname_lower in BLOCKED_HOSTNAMES:
raise SecurityError("不允许访问该主机")
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
raise SecurityError("不允许访问内网域名")
try:
ip = ipaddress.ip_address(hostname)
except ValueError:
return url
for network in PRIVATE_NETWORKS:
if ip in network:
raise SecurityError("不允许访问内网或本地地址")
return url
def validate_url(raw_url: str) -> str:
return _normalize_url(raw_url)
def get_max_sessions() -> int:
return max(1, int(os.getenv("MAX_SESSIONS", "1")))
def get_idle_timeout() -> int:
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
def get_viewport_size() -> tuple[int, int]:
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
return width, height
def get_screencast_quality() -> int:
quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
return min(100, max(10, quality))