This commit is contained in:
Timothy Jaeryang Baek committed 2026-11-01 23:55:04 +09:00
1 parent 13afaba925
commit 1c894e0e8a
4 files changed
+461 -90

No files matched your search

+224 -40
View File
@@ -1,4 +1,5 @@
"""Headless Chrome tabs. CDP stays on private pipes, never a shared TCP port."""
from __future__ import annotations
import asyncio
@@ -21,8 +22,20 @@ log = logging.getLogger(__name__)
def find_browser():
configured = os.environ.get("OPEN_TERMINAL_BROWSER_EXECUTABLE")
candidates = [configured] if configured else []
candidates += [shutil.which(name) for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "msedge")]
candidates += ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
candidates += [
shutil.which(name)
for name in (
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"msedge",
)
]
candidates += [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]
return next((p for p in candidates if p and Path(p).is_file()), None)
@@ -43,7 +56,9 @@ class Host:
future = self.pending.pop(message["id"], None)
if future and not future.done():
if "error" in message:
future.set_exception(RuntimeError(message["error"]["message"]))
future.set_exception(
RuntimeError(message["error"]["message"])
)
else:
future.set_result(message.get("result", {}))
elif handler := self.handlers.get(message.get("sessionId")):
@@ -91,6 +106,7 @@ class Host:
self.reader.cancel()
await asyncio.gather(self.reader, return_exceptions=True)
hosts = {}
host_lock = asyncio.Lock()
@@ -101,71 +117,118 @@ async def get_host(session):
return hosts[session.owner]
binary = find_browser()
if not binary:
raise RuntimeError("Chrome mode requires Chromium. Install it or set OPEN_TERMINAL_BROWSER_EXECUTABLE.")
raise RuntimeError(
"Chrome mode requires Chromium. Install it or set OPEN_TERMINAL_BROWSER_EXECUTABLE."
)
if os.name == "nt":
raise RuntimeError("Managed Chrome currently requires a POSIX host")
# Let Chrome create its own profile as the same OS user as the terminal.
suffix = hashlib.sha256(session.owner.encode()).hexdigest()[:24]
profile = str(Path(session.fs.home) / ".local/state/open-terminal/browser" / suffix)
args = [binary, "--headless=new", "--remote-debugging-pipe", f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", "about:blank"]
profile = str(
Path(session.fs.home) / ".local/state/open-terminal/browser" / suffix
)
args = [
binary,
"--headless=new",
"--remote-debugging-pipe",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
"about:blank",
]
# Chrome's pipe transport uses fd 3/4. A tiny exec shim maps subprocess stdin/stdout.
shim = "import os,sys; os.dup2(0,3); os.dup2(1,4); os.execv(sys.argv[1],sys.argv[1:])"
command = [sys.executable, "-c", shim, *args]
kwargs = {}
if session.fs.username:
import pwd
account = pwd.getpwnam(session.fs.username)
if os.getuid() == 0:
kwargs.update(user=account.pw_uid, group=account.pw_gid, extra_groups=[])
kwargs.update(
user=account.pw_uid, group=account.pw_gid, extra_groups=[]
)
else:
command = ["sudo", "-H", "-n", "-u", session.fs.username, "--", *command]
process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=sanitized_environ({"HOME": session.fs.home}), limit=16 * 1024 * 1024, **kwargs)
command = [
"sudo",
"-H",
"-n",
"-u",
session.fs.username,
"--",
*command,
]
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env=sanitized_environ({"HOME": session.fs.home}),
limit=16 * 1024 * 1024,
**kwargs,
)
host = Host(process)
try:
await host.send("Browser.getVersion")
except Exception:
await host.close()
raise RuntimeError("Chrome could not start. Check its installation, OS user, and sandbox support.")
raise RuntimeError(
"Chrome could not start. Check its installation, OS user, and sandbox support."
)
hosts[session.owner] = host
return host
async def shutdown_hosts():
await asyncio.gather(*(host.close() for host in hosts.values()), return_exceptions=True)
await asyncio.gather(
*(host.close() for host in hosts.values()), return_exceptions=True
)
hosts.clear()
class Chrome:
def __init__(self, session, host, target, cdp_session):
self.session, self.host, self.target, self.cdp_session = session, host, target, cdp_session
self.session, self.host, self.target, self.cdp_session = (
session,
host,
target,
cdp_session,
)
self.peer = None
self.frame = asyncio.Queue(maxsize=1)
self.state_changed = asyncio.Event()
self.width, self.height = 1024, 768
self.quality = 65
self.visible = True
self.navigation = None
self.error = ""
host.handlers[cdp_session] = self.event
@classmethod
async def create(cls, session):
host = await get_host(session)
chrome = None
target = (await host.send("Target.createTarget", {"url": "about:blank"}))["targetId"]
target = (await host.send("Target.createTarget", {"url": "about:blank"}))[
"targetId"
]
try:
attached = await host.send("Target.attachToTarget", {"targetId": target, "flatten": True})
attached = await host.send(
"Target.attachToTarget", {"targetId": target, "flatten": True}
)
chrome = cls(session, host, target, attached["sessionId"])
await chrome.send("Page.enable")
await chrome.send("Runtime.enable")
await chrome.send("Page.addScriptToEvaluateOnNewDocument", {"source": """
await chrome.send(
"Page.addScriptToEvaluateOnNewDocument",
{"source": """
window.open = url => { if (url) location.assign(url); return null; };
addEventListener('click', e => {const a=e.target.closest?.('a[target]');
if(a && a.target !== '_self'){e.preventDefault(); location.assign(a.href);}},true);
"""})
"""},
)
if session.url:
await chrome.send("Page.navigate", {"url": target_url(session.url)})
chrome.navigate(session.url)
return chrome
except BaseException:
if chrome:
@@ -177,51 +240,105 @@ class Chrome:
async def send(self, method, params=None):
return await self.host.send(method, params, self.cdp_session)
def navigate(self, url):
url = target_url(url)
if self.navigation:
self.navigation.cancel()
self.error = ""
async def load():
try:
result = await self.send("Page.navigate", {"url": url})
self.error = result.get("errorText", "")
except RuntimeError as exc:
self.error = str(exc)
finally:
self.state_changed.set()
# Slow sites must not block opening the viewer or its navigation controls.
self.navigation = asyncio.create_task(load())
def event(self, method, params):
if method == "Page.screencastFrame":
# Chrome applies backpressure until the frame is acknowledged.
if self.frame.full():
self.frame.get_nowait()
self.frame.put_nowait(params)
elif method in ("Page.frameNavigated", "Page.navigatedWithinDocument", "Page.loadEventFired"):
elif method in (
"Page.frameNavigated",
"Page.navigatedWithinDocument",
"Page.loadEventFired",
):
if method == "Page.loadEventFired":
self.error = ""
self.state_changed.set()
async def state(self):
history = await self.send("Page.getNavigationHistory")
entries, index = history["entries"], history["currentIndex"]
entry = entries[index] if entries else {}
self.session.url = entry.get("url", self.session.url)
url = entry.get("url", "")
if url.startswith(("http://", "https://")):
self.session.url = url
self.session.title = entry.get("title", "")
return {"type": "state", "url": self.session.url, "title": self.session.title,
"canGoBack": index > 0, "canGoForward": index < len(entries)-1}
return {
"type": "state",
"url": self.session.url,
"title": self.session.title,
"error": self.error,
"canGoBack": index > 0,
"canGoForward": index < len(entries) - 1,
}
async def capture(self):
await self.send("Page.stopScreencast")
if not self.visible:
return
await self.send("Page.startScreencast", {"format": "jpeg", "quality": self.quality,
"maxWidth": self.width, "maxHeight": self.height, "everyNthFrame": 1})
await self.send(
"Page.startScreencast",
{
"format": "jpeg",
"quality": self.quality,
"maxWidth": self.width,
"maxHeight": self.height,
"everyNthFrame": 1,
},
)
async def stream(self, ws: WebSocket):
await ws.accept()
if self.peer:
with contextlib.suppress(Exception):
await self.peer.close(code=4000, reason="Browser opened in another viewer")
await self.peer.close(
code=4000, reason="Browser opened in another viewer"
)
self.peer = ws
tasks = []
try:
await ws.send_json(await self.state())
await self.capture()
async def frames():
while True:
frame = await self.frame.get()
await ws.send_json({"type": "frame", "data": frame["data"], "width": self.width, "height": self.height})
await self.send("Page.screencastFrameAck", {"sessionId": frame["sessionId"]})
await ws.send_json(
{
"type": "frame",
"data": frame["data"],
"width": self.width,
"height": self.height,
}
)
await self.send(
"Page.screencastFrameAck", {"sessionId": frame["sessionId"]}
)
async def states():
while True:
await self.state_changed.wait()
self.state_changed.clear()
await ws.send_json(await self.state())
async def commands():
while True:
msg = await ws.receive_json()
@@ -229,12 +346,15 @@ class Chrome:
continue
kind = msg.get("type")
if kind == "navigate":
await self.send("Page.navigate", {"url": target_url(str(msg.get("url", "")))})
self.navigate(str(msg.get("url", "")))
elif kind in ("back", "forward"):
history = await self.send("Page.getNavigationHistory")
index = history["currentIndex"] + (-1 if kind == "back" else 1)
if 0 <= index < len(history["entries"]):
await self.send("Page.navigateToHistoryEntry", {"entryId": history["entries"][index]["id"]})
await self.send(
"Page.navigateToHistoryEntry",
{"entryId": history["entries"][index]["id"]},
)
elif kind == "reload":
await self.send("Page.reload")
elif kind == "visibility":
@@ -244,31 +364,92 @@ class Chrome:
self.width = max(240, min(2560, int(msg.get("width", 1024))))
self.height = max(200, min(2160, int(msg.get("height", 768))))
mobile = msg.get("mobile") is True
await self.send("Emulation.setDeviceMetricsOverride", {"width": self.width, "height": self.height, "deviceScaleFactor": 1, "mobile": mobile})
await self.send("Emulation.setTouchEmulationEnabled", {"enabled": mobile})
await self.send("Emulation.setUserAgentOverride", {"userAgent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36" if mobile else ""})
await self.send(
"Emulation.setDeviceMetricsOverride",
{
"width": self.width,
"height": self.height,
"deviceScaleFactor": 1,
"mobile": mobile,
},
)
await self.send(
"Emulation.setTouchEmulationEnabled", {"enabled": mobile}
)
await self.send(
"Emulation.setUserAgentOverride",
{
"userAgent": (
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
if mobile
else ""
)
},
)
await self.capture()
elif kind == "quality":
self.quality = {"low": 40, "balanced": 65, "crisp": 90}.get(msg.get("quality"), 65)
self.quality = {"low": 40, "balanced": 65, "crisp": 90}.get(
msg.get("quality"), 65
)
await self.capture()
elif kind == "mouse":
event = {k: msg[k] for k in ("type", "x", "y", "button", "buttons", "clickCount", "deltaX", "deltaY", "modifiers") if k in msg}
event = {
k: msg[k]
for k in (
"type",
"x",
"y",
"button",
"buttons",
"clickCount",
"deltaX",
"deltaY",
"modifiers",
)
if k in msg
}
event["type"] = msg.get("event")
if event["type"] in ("mousePressed", "mouseReleased", "mouseMoved", "mouseWheel"):
if event["type"] in (
"mousePressed",
"mouseReleased",
"mouseMoved",
"mouseWheel",
):
await self.send("Input.dispatchMouseEvent", event)
elif kind == "key":
event = {k: msg[k] for k in ("key", "code", "text", "modifiers", "windowsVirtualKeyCode", "commands") if k in msg}
event = {
k: msg[k]
for k in (
"key",
"code",
"text",
"modifiers",
"windowsVirtualKeyCode",
"commands",
)
if k in msg
}
event["type"] = msg.get("event")
if event["type"] in ("keyDown", "keyUp"):
await self.send("Input.dispatchKeyEvent", event)
elif kind == "paste":
await self.send("Input.insertText", {"text": str(msg.get("text", ""))[:1_000_000]})
tasks = [asyncio.create_task(frames()), asyncio.create_task(states()), asyncio.create_task(commands())]
await self.send(
"Input.insertText",
{"text": str(msg.get("text", ""))[:1_000_000]},
)
tasks = [
asyncio.create_task(frames()),
asyncio.create_task(states()),
asyncio.create_task(commands()),
]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
except (WebSocketDisconnect, RuntimeError, ValueError, TypeError):
with contextlib.suppress(Exception):
await ws.close(code=1011)
finally:
with contextlib.suppress(Exception):
await ws.close()
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
@@ -278,6 +459,9 @@ class Chrome:
await self.send("Page.stopScreencast")
async def close(self):
if self.navigation:
self.navigation.cancel()
await asyncio.gather(self.navigation, return_exceptions=True)
if self.peer:
with contextlib.suppress(Exception):
await self.peer.close()
+39 -11
View File
@@ -1,4 +1,5 @@
"""Browser resource rewriting, adapted from Computer's browser proxy."""
import html
import json
import re
@@ -8,10 +9,16 @@ from urllib.parse import quote, urljoin, urlsplit, urlunsplit
_URL_ATTRIBUTES = {"href", "src", "action", "poster", "data", "formaction"}
_SKIP_SCHEMES = ("data:", "javascript:", "mailto:", "tel:", "about:", "blob:")
def proxy_path(prefix: str, url: str) -> str:
"""Return the same-origin URL used by the Browser iframe for an upstream URL."""
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ValueError("Only HTTP(S) URLs can be opened in Browser")
host = quote(parsed.netloc, safe=":[]")
path = quote(parsed.path or "/", safe="/%:@!$&'()*+,;=-._~")
@@ -22,10 +29,23 @@ def proxy_path(prefix: str, url: str) -> str:
def target_url(url: str) -> str:
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ValueError("Only HTTP(S) URLs can be opened in Browser")
parsed.port # Validate malformed port numbers before dispatching a request.
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.query, parsed.fragment))
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path or "/",
parsed.query,
parsed.fragment,
)
)
def rewrite_url(value: str, base_url: str, prefix: str) -> str:
@@ -92,7 +112,9 @@ class _HtmlRewriter(HTMLParser):
r"(?i)(url\s*=\s*)([^;]+)",
lambda match: (
match.group(1)
+ rewrite_url(match.group(2).strip(), self.base_url, self.prefix)
+ rewrite_url(
match.group(2).strip(), self.base_url, self.prefix
)
),
value,
)
@@ -112,13 +134,17 @@ def rewrite_html(content: str, base_url: str, prefix: str, final_url: str) -> st
parser.feed(content)
parser.close()
config = json.dumps({"prefix": prefix, "url": final_url}).replace("<", "\\u003c")
runtime = (f"<script>window.__terminalBrowser={config};</script>"
f'<script src="{html.escape(prefix, quote=True)}/runtime.js"></script>')
runtime = (
f"<script>window.__terminalBrowser={config};</script>"
f'<script src="{html.escape(prefix, quote=True)}/runtime.js"></script>'
)
rendered = "".join(parser.output)
if urlsplit(final_url).hostname in {"localhost", "127.0.0.1", "::1"}:
rendered = rewrite_javascript(rendered, final_url, prefix)
if re.search(r"<head(?:\s[^>]*)?>", rendered, re.I):
return re.sub(r"(<head(?:\s[^>]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I)
return re.sub(
r"(<head(?:\s[^>]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I
)
return runtime + rendered
@@ -127,7 +153,9 @@ def rewrite_css(content: str, base_url: str, prefix: str) -> str:
quote, value = match.group(1), match.group(2)
return f"url({quote}{rewrite_url(value, base_url, prefix)}{quote})"
content = re.sub(r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", replace_url, content, flags=re.I)
content = re.sub(
r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", replace_url, content, flags=re.I
)
return re.sub(
r"(@import\s+(?:url\()?['\"])([^'\"]+)",
lambda match: match.group(1) + rewrite_url(match.group(2), base_url, prefix),
@@ -139,10 +167,11 @@ def rewrite_css(content: str, base_url: str, prefix: str) -> str:
def rewrite_javascript(content: str, base_url: str, prefix: str) -> str:
"""Rewrite root-relative ES module imports used by local dev servers."""
# Vite builds hot-update imports dynamically from `base`, outside import declarations.
if urlsplit(base_url).path.endswith('/@vite/client'):
if urlsplit(base_url).path.endswith("/@vite/client"):
content = re.sub(
r'(\bconst base\s*=\s*)(["\'])(/[^"\']*)\2',
lambda match: match[1] + json.dumps(proxy_path(prefix, urljoin(base_url, match[3]))),
lambda match: match[1]
+ json.dumps(proxy_path(prefix, urljoin(base_url, match[3]))),
content,
)
return re.sub(
@@ -150,4 +179,3 @@ def rewrite_javascript(content: str, base_url: str, prefix: str) -> str:
lambda match: match.group(1) + rewrite_url(match.group(2), base_url, prefix),
content,
)
+196 -37
View File
@@ -1,11 +1,12 @@
"""Owned browser sessions and capability-scoped iframe/stream endpoints."""
from __future__ import annotations
import asyncio
import contextlib
import hmac
import json
import secrets
import posixpath
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -13,17 +14,31 @@ from urllib.parse import urljoin, urlsplit, urlunsplit
import httpx
import websockets
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import Response, StreamingResponse
from starlette.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from open_terminal.dependencies import get_filesystem, verify_api_key
from open_terminal.utils.fs import UserFS
from .rewrite import proxy_path, rewrite_css, rewrite_html, rewrite_javascript, target_url
from .rewrite import (
proxy_path,
rewrite_css,
rewrite_html,
rewrite_javascript,
target_url,
)
router = APIRouter(prefix="/browser", include_in_schema=False)
@dataclass
class Session:
id: str
@@ -39,6 +54,7 @@ class Session:
peers: set = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class BrowserCORSMiddleware(CORSMiddleware):
async def __call__(self, scope, receive, send):
if scope.get("path", "").startswith("/browser/view/"):
@@ -69,9 +85,28 @@ async def reap_idle():
if time.monotonic() - session.touched > IDLE_TIMEOUT:
await remove(session)
profiles: dict[str, httpx.AsyncClient] = {}
HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"}
SKIP_RESPONSE = HOP | {"content-encoding", "content-length", "content-security-policy", "content-security-policy-report-only", "x-frame-options", "set-cookie", "access-control-allow-origin", "access-control-allow-credentials"}
HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
SKIP_RESPONSE = HOP | {
"content-encoding",
"content-length",
"content-security-policy",
"content-security-policy-report-only",
"x-frame-options",
"set-cookie",
"access-control-allow-origin",
"access-control-allow-credentials",
}
def owner(request):
@@ -80,7 +115,11 @@ def owner(request):
def owned(request, sid):
session = sessions.get(sid)
if not session or session.owner != owner(request) or session.chat != request.headers.get("x-session-id", ""):
if (
not session
or session.owner != owner(request)
or session.chat != request.headers.get("x-session-id", "")
):
raise HTTPException(404, "Browser session not found")
session.touched = time.monotonic()
return session
@@ -96,13 +135,21 @@ def capability(sid, token):
def payload(session):
from .chrome import find_browser
return {"id": session.id, "token": session.token, "url": session.url, "title": session.title,
"mode": session.mode, "chrome_available": find_browser() is not None}
return {
"id": session.id,
"token": session.token,
"url": session.url,
"title": session.title,
"mode": session.mode,
"chrome_available": find_browser() is not None,
}
class CreateSession(BaseModel):
url: str = Field(default="", max_length=16384)
class UpdateSession(BaseModel):
url: str | None = Field(default=None, max_length=16384)
title: str | None = Field(default=None, max_length=512)
@@ -117,7 +164,9 @@ def valid_url(url):
@router.post("/sessions", dependencies=[Depends(verify_api_key)])
async def create(request: Request, body: CreateSession, fs: UserFS = Depends(get_filesystem)):
async def create(
request: Request, body: CreateSession, fs: UserFS = Depends(get_filesystem)
):
global reaper
if reaper is None or reaper.done():
reaper = asyncio.create_task(reap_idle())
@@ -125,10 +174,19 @@ async def create(request: Request, body: CreateSession, fs: UserFS = Depends(get
raise HTTPException(409, "Close a browser tab before opening another")
if body.url:
valid_url(body.url)
session = Session(secrets.token_hex(16), secrets.token_urlsafe(32), owner(request), request.headers.get("x-session-id", ""), fs, url=body.url)
session = Session(
secrets.token_hex(16),
secrets.token_urlsafe(32),
owner(request),
request.headers.get("x-session-id", ""),
fs,
url=body.url,
)
sessions[session.id] = session
if session.owner not in profiles:
profiles[session.owner] = httpx.AsyncClient(follow_redirects=False, timeout=httpx.Timeout(60, connect=10))
profiles[session.owner] = httpx.AsyncClient(
follow_redirects=False, timeout=httpx.Timeout(60, connect=10)
)
return payload(session)
@@ -151,6 +209,7 @@ async def update(request: Request, sid: str, body: UpdateSession):
session.title = body.title
if body.mode == "chrome" and session.chrome is None:
from .chrome import Chrome
try:
session.chrome = await Chrome.create(session)
except Exception as exc:
@@ -176,53 +235,109 @@ async def shutdown():
reaper.cancel()
await asyncio.gather(reaper, return_exceptions=True)
reaper = None
await asyncio.gather(*(s.chrome.close() for s in sessions.values() if s.chrome), return_exceptions=True)
await asyncio.gather(
*(s.chrome.close() for s in sessions.values() if s.chrome),
return_exceptions=True,
)
sessions.clear()
await asyncio.gather(*(p.aclose() for p in profiles.values()), return_exceptions=True)
await asyncio.gather(
*(p.aclose() for p in profiles.values()), return_exceptions=True
)
profiles.clear()
from .chrome import shutdown_hosts
await shutdown_hosts()
def view_prefix(request, sid, token):
# The authenticated WebUI relay supplies the public prefix; direct clients use this route.
return request.headers.get("x-browser-prefix") or f"/browser/view/{sid}/{token}"
# Relative references survive both direct access and the WebUI relay unchanged.
return posixpath.relpath(
f"/browser/view/{sid}/{token}", posixpath.dirname(request.url.path)
)
def response_headers(upstream):
return {**{k: v for k, v in upstream.headers.items() if k.lower() not in SKIP_RESPONSE},
"access-control-allow-origin": "*", "referrer-policy": "no-referrer", "cache-control": "no-store",
# Keep remote pages isolated even when their URL is opened outside the iframe.
"content-security-policy": "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"}
return {
**{k: v for k, v in upstream.headers.items() if k.lower() not in SKIP_RESPONSE},
"access-control-allow-origin": "*",
"referrer-policy": "no-referrer",
"cache-control": "no-store",
# Keep remote pages isolated even when their URL is opened outside the iframe.
"content-security-policy": "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads",
}
@router.get("/view/{sid}/{token}/runtime.js")
async def runtime(sid: str, token: str):
capability(sid, token)
return Response(Path(__file__).with_name("runtime.js").read_text(), media_type="application/javascript", headers={"access-control-allow-origin": "*", "cache-control": "no-store"})
return Response(
Path(__file__).with_name("runtime.js").read_text(),
media_type="application/javascript",
headers={"access-control-allow-origin": "*", "cache-control": "no-store"},
)
@router.api_route("/view/{sid}/{token}/frame/{scheme}/{host}/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
async def frame(request: Request, sid: str, token: str, scheme: str, host: str, path: str):
@router.api_route(
"/view/{sid}/{token}/frame/{scheme}/{host}/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
)
async def frame(
request: Request, sid: str, token: str, scheme: str, host: str, path: str
):
session = capability(sid, token)
url = valid_url(urlunsplit((scheme, host, "/" + path, str(request.query_params), "")))
url = valid_url(
urlunsplit((scheme, host, "/" + path, str(request.query_params), ""))
)
prefix = view_prefix(request, sid, token)
if request.method == "OPTIONS" and request.headers.get("access-control-request-method"):
return Response(headers={"access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS", "access-control-allow-headers": request.headers.get("access-control-request-headers", "")})
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP | {"host", "cookie", "authorization", "accept-encoding", "content-length", "origin", "referer"} and not k.lower().startswith("x-")}
if request.method == "OPTIONS" and request.headers.get(
"access-control-request-method"
):
return Response(
headers={
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS",
"access-control-allow-headers": request.headers.get(
"access-control-request-headers", ""
),
}
)
headers = {
k: v
for k, v in request.headers.items()
if k.lower()
not in HOP
| {
"host",
"cookie",
"authorization",
"accept-encoding",
"content-length",
"origin",
"referer",
}
and not k.lower().startswith("x-")
}
headers["accept-encoding"] = "gzip, deflate"
if request.headers.get("origin"):
headers["origin"] = f"{scheme}://{host}"
client = profiles[session.owner]
try:
upstream = await client.send(client.build_request(request.method, url, headers=headers, content=request.stream()), stream=True)
upstream = await client.send(
client.build_request(
request.method, url, headers=headers, content=request.stream()
),
stream=True,
)
except httpx.HTTPError as exc:
raise HTTPException(502, "Cannot connect to this website") from exc
output_headers = response_headers(upstream)
if location := upstream.headers.get("location"):
output_headers["location"] = proxy_path(prefix, urljoin(url, location))
content_type = upstream.headers.get("content-type", "")
rewrite = any(t in content_type for t in ("text/html", "application/xhtml", "text/css", "javascript"))
rewrite = any(
t in content_type
for t in ("text/html", "application/xhtml", "text/css", "javascript")
)
if rewrite:
try:
data = bytearray()
@@ -237,17 +352,25 @@ async def frame(request: Request, sid: str, token: str, scheme: str, host: str,
text = rewrite_css(text, url, prefix)
else:
text = rewrite_javascript(text, url, prefix)
output_headers["content-type"] = content_type.split(";")[0] + "; charset=utf-8"
return Response(text, status_code=upstream.status_code, headers=output_headers)
output_headers["content-type"] = (
content_type.split(";")[0] + "; charset=utf-8"
)
return Response(
text, status_code=upstream.status_code, headers=output_headers
)
finally:
await upstream.aclose()
async def stream():
try:
async for chunk in upstream.aiter_bytes():
yield chunk
finally:
await upstream.aclose()
return StreamingResponse(stream(), status_code=upstream.status_code, headers=output_headers)
return StreamingResponse(
stream(), status_code=upstream.status_code, headers=output_headers
)
@router.websocket("/view/{sid}/{token}/ws")
@@ -266,26 +389,62 @@ async def proxy_socket(ws: WebSocket, sid: str, token: str):
parsed = urlsplit(url)
if parsed.scheme not in ("ws", "wss"):
raise ValueError("Invalid WebSocket URL")
http_url = valid_url(urlunsplit(("https" if parsed.scheme == "wss" else "http", parsed.netloc, parsed.path, parsed.query, "")))
http_url = valid_url(
urlunsplit(
(
"https" if parsed.scheme == "wss" else "http",
parsed.netloc,
parsed.path,
parsed.query,
"",
)
)
)
protocols = message.get("protocols") or []
if isinstance(protocols, str):
protocols = [protocols]
prepared = profiles[session.owner].build_request("GET", http_url)
headers = {"cookie": prepared.headers["cookie"]} if "cookie" in prepared.headers else None
async with websockets.connect(url, subprotocols=protocols, additional_headers=headers, max_size=16 * 1024 * 1024) as upstream:
headers = (
{"cookie": prepared.headers["cookie"]}
if "cookie" in prepared.headers
else None
)
async with websockets.connect(
url,
subprotocols=protocols,
additional_headers=headers,
max_size=16 * 1024 * 1024,
) as upstream:
await ws.send_json({"type": "open", "protocol": upstream.subprotocol or ""})
async def send():
while True:
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
return
await upstream.send(msg.get("bytes") if msg.get("bytes") is not None else msg["text"])
await upstream.send(
msg.get("bytes")
if msg.get("bytes") is not None
else msg["text"]
)
async def receive():
async for msg in upstream:
await (ws.send_bytes(msg) if isinstance(msg, bytes) else ws.send_text(msg))
await (
ws.send_bytes(msg)
if isinstance(msg, bytes)
else ws.send_text(msg)
)
tasks = [asyncio.create_task(send()), asyncio.create_task(receive())]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
except (WebSocketDisconnect, ValueError, TimeoutError, httpx.HTTPError, websockets.WebSocketException):
except (
WebSocketDisconnect,
ValueError,
TimeoutError,
httpx.HTTPError,
websockets.WebSocketException,
):
with contextlib.suppress(Exception):
await ws.close(code=1011)
finally:
+2 -2
View File
@@ -2,8 +2,8 @@
const config = window.__terminalBrowser;
if (!config) return;
const prefix = `${config.prefix}/frame`;
const api = config.prefix;
const api = new URL(config.prefix + "/", location.href).pathname.replace(/\/$/, "");
const prefix = `${api}/frame`;
const nativeFetch = window.fetch.bind(window);
const nativeWebSocket = window.WebSocket;