e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
544 lines
18 KiB
Python
544 lines
18 KiB
Python
# Copyright 2025 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
HTTP and WebSocket proxy routes for reaching services inside sandboxes via the lifecycle API.
|
|
"""
|
|
|
|
import hmac
|
|
import logging
|
|
from collections.abc import AsyncIterator, Mapping
|
|
from typing import Optional
|
|
|
|
import anyio
|
|
import httpx
|
|
import websockets
|
|
from fastapi import APIRouter, Request, WebSocket, status
|
|
from fastapi.exceptions import HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from starlette.websockets import WebSocketDisconnect
|
|
from websockets.asyncio.client import ClientConnection
|
|
from websockets.typing import Origin
|
|
|
|
from opensandbox_server.api import lifecycle
|
|
from opensandbox_server.api.schema import Endpoint
|
|
from opensandbox_server.middleware.auth import SANDBOX_API_KEY_HEADER
|
|
from opensandbox_server.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER, OPEN_SANDBOX_SECURE_ACCESS_HEADER
|
|
from opensandbox_server.tenants.context import set_current_tenant
|
|
from opensandbox_server.tenants.provider import TenantProviderUnavailable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# RFC 2616 Section 13.5.1
|
|
HOP_BY_HOP_HEADERS = {
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
}
|
|
|
|
# Headers that shouldn't be forwarded to untrusted/internal backends
|
|
SENSITIVE_HEADERS = {
|
|
"authorization",
|
|
"cookie",
|
|
SANDBOX_API_KEY_HEADER.lower(),
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower(),
|
|
}
|
|
|
|
FORWARDED_HEADERS = {
|
|
"forwarded",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-port",
|
|
"x-forwarded-proto",
|
|
"x-real-ip",
|
|
}
|
|
|
|
# Handled by websockets on the outbound handshake; do not duplicate on additional_headers
|
|
WEBSOCKET_HANDSHAKE_HEADERS = {
|
|
"origin",
|
|
"sec-websocket-extensions",
|
|
"sec-websocket-key",
|
|
"sec-websocket-protocol",
|
|
"sec-websocket-version",
|
|
}
|
|
|
|
router = APIRouter(tags=["Sandboxes"])
|
|
|
|
|
|
def _build_proxy_target_url(
|
|
endpoint: Endpoint,
|
|
full_path: str,
|
|
query_string: str,
|
|
*,
|
|
websocket: bool = False,
|
|
) -> str:
|
|
"""Build the backend URL from an endpoint plus optional path/query suffix.
|
|
|
|
For HTTP, ``query_string`` is omitted from the URL so httpx can pass it via ``params=``
|
|
(avoids duplicate encoding issues). For WebSocket, the query is appended to the URI.
|
|
"""
|
|
scheme = "ws" if websocket else "http"
|
|
base = endpoint.endpoint.rstrip("/")
|
|
normalized_path = full_path.lstrip("/")
|
|
url = f"{scheme}://{base}"
|
|
if normalized_path:
|
|
url = f"{url}/{normalized_path}"
|
|
if query_string and websocket:
|
|
url = f"{url}?{query_string}"
|
|
return url
|
|
|
|
|
|
def _filter_proxy_headers(
|
|
headers: Mapping[str, str],
|
|
endpoint_headers: Optional[dict[str, str]] = None,
|
|
*,
|
|
extra_excluded: Optional[set[str]] = None,
|
|
connection_header: Optional[str] = None,
|
|
) -> dict[str, str]:
|
|
"""Drop transport/auth headers while preserving app-level headers.
|
|
|
|
Endpoint-resolved headers are merged for routing, except secure-access
|
|
credentials which callers must explicitly provide on server-proxy requests.
|
|
"""
|
|
excluded = set(HOP_BY_HOP_HEADERS) | set(SENSITIVE_HEADERS) | set(FORWARDED_HEADERS)
|
|
if extra_excluded:
|
|
excluded.update(extra_excluded)
|
|
if connection_header:
|
|
excluded.update(
|
|
h.strip().lower() for h in connection_header.split(",") if h.strip()
|
|
)
|
|
|
|
forwarded: dict[str, str] = {}
|
|
for key, value in headers.items():
|
|
key_lower = key.lower()
|
|
if key_lower != "host" and key_lower not in excluded:
|
|
forwarded[key] = value
|
|
|
|
if endpoint_headers:
|
|
endpoint_header_excluded = {
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower(),
|
|
OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower(),
|
|
} | FORWARDED_HEADERS
|
|
forwarded.update(
|
|
{
|
|
key: value
|
|
for key, value in endpoint_headers.items()
|
|
if key.lower() not in endpoint_header_excluded
|
|
}
|
|
)
|
|
return forwarded
|
|
|
|
|
|
def _set_forwarded_headers(
|
|
headers: dict[str, str], request: Request | WebSocket
|
|
) -> None:
|
|
"""Rebuild proxy headers from the connection observed by this server."""
|
|
scheme = request.url.scheme.lower()
|
|
if scheme == "ws":
|
|
scheme = "http"
|
|
elif scheme == "wss":
|
|
scheme = "https"
|
|
headers["X-Forwarded-Proto"] = scheme
|
|
|
|
inbound_host = request.headers.get("host", "")
|
|
if inbound_host:
|
|
headers["X-Forwarded-Host"] = inbound_host
|
|
if request.client:
|
|
headers["X-Forwarded-For"] = request.client.host
|
|
|
|
|
|
def _schedule_proxy_renew(request: Request | WebSocket, sandbox_id: str) -> None:
|
|
proxy_renew = getattr(request.app.state, "proxy_renew_coordinator", None)
|
|
if proxy_renew is not None:
|
|
proxy_renew.schedule(sandbox_id)
|
|
|
|
|
|
async def _authenticate_websocket_tenant(websocket: WebSocket) -> bool:
|
|
"""Authenticate WebSocket connections in multi-tenant mode.
|
|
|
|
BaseHTTPMiddleware only intercepts HTTP requests, so WebSocket
|
|
connections must be authenticated here to establish tenant context.
|
|
Returns True if the request is authorized (or single-tenant mode).
|
|
"""
|
|
import asyncio
|
|
|
|
provider = getattr(websocket.app.state, "tenant_provider", None)
|
|
if provider is None:
|
|
return True
|
|
|
|
api_key = websocket.headers.get(SANDBOX_API_KEY_HEADER)
|
|
if not api_key:
|
|
await _fail_client_websocket(
|
|
websocket, status.WS_1008_POLICY_VIOLATION, "missing API key"
|
|
)
|
|
return False
|
|
|
|
try:
|
|
tenant = await asyncio.to_thread(provider.lookup, api_key)
|
|
except TenantProviderUnavailable:
|
|
await _fail_client_websocket(
|
|
websocket, status.WS_1011_INTERNAL_ERROR, "tenant provider unavailable"
|
|
)
|
|
return False
|
|
|
|
if tenant is None:
|
|
await _fail_client_websocket(
|
|
websocket, status.WS_1008_POLICY_VIOLATION, "invalid API key"
|
|
)
|
|
return False
|
|
|
|
set_current_tenant(tenant)
|
|
return True
|
|
|
|
|
|
async def _stream_backend_response(resp: httpx.Response) -> AsyncIterator[bytes]:
|
|
"""
|
|
Yield backend body chunks without httpx content decoding and always close the response.
|
|
|
|
httpx requires ``await resp.aclose()`` for ``stream=True`` responses so connections
|
|
return to the pool; Starlette's StreamingResponse does not do this automatically.
|
|
Use ``aiter_raw`` so forwarded ``content-encoding`` headers still match the body bytes.
|
|
"""
|
|
try:
|
|
async for chunk in resp.aiter_raw():
|
|
yield chunk
|
|
finally:
|
|
await resp.aclose()
|
|
|
|
|
|
def _verify_secure_access(endpoint: Endpoint, caller_headers: Mapping[str, str]) -> None:
|
|
"""Enforce OpenSandbox-Secure-Access validation on server-proxy requests.
|
|
|
|
When endpoint resolution returns a secure-access token, the caller must
|
|
supply the same header value. Raises 401 for missing or mismatched tokens.
|
|
Uses constant-time comparison to avoid timing side-channels.
|
|
"""
|
|
if not endpoint.headers:
|
|
return
|
|
expected_token = endpoint.headers.get(OPEN_SANDBOX_SECURE_ACCESS_HEADER)
|
|
if not expected_token:
|
|
return
|
|
caller_token = None
|
|
for key, value in caller_headers.items():
|
|
if key.lower() == OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower():
|
|
caller_token = value
|
|
break
|
|
if not caller_token or not hmac.compare_digest(
|
|
caller_token.encode(), expected_token.encode()
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={
|
|
"code": "MISSING_OR_INVALID_SECURE_ACCESS",
|
|
"message": (
|
|
"This sandbox requires the "
|
|
f"{OPEN_SANDBOX_SECURE_ACCESS_HEADER} header for access."
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
async def _proxy_http_request(
|
|
request: Request,
|
|
sandbox_id: str,
|
|
port: int,
|
|
full_path: str,
|
|
) -> StreamingResponse:
|
|
endpoint = lifecycle.sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)
|
|
_verify_secure_access(endpoint, request.headers)
|
|
_schedule_proxy_renew(request, sandbox_id)
|
|
query_string = request.url.query
|
|
target_url = _build_proxy_target_url(endpoint, full_path, query_string, websocket=False)
|
|
client: httpx.AsyncClient = request.app.state.http_client
|
|
|
|
try:
|
|
upgrade_header = request.headers.get("Upgrade", "")
|
|
if upgrade_header.lower() == "websocket":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Websocket upgrade is not supported yet",
|
|
)
|
|
|
|
headers = _filter_proxy_headers(
|
|
request.headers,
|
|
endpoint.headers,
|
|
connection_header=request.headers.get("connection"),
|
|
)
|
|
# Forwarded headers are stripped above and rebuilt from the connection
|
|
# observed by this trusted proxy, so clients cannot spoof transport state.
|
|
_set_forwarded_headers(headers, request)
|
|
|
|
stream_body = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
|
req = client.build_request(
|
|
method=request.method,
|
|
url=target_url,
|
|
params=query_string if query_string else None,
|
|
headers=headers,
|
|
content=request.stream() if stream_body else None,
|
|
)
|
|
|
|
resp = await client.send(req, stream=True)
|
|
|
|
hop_by_hop = set(HOP_BY_HOP_HEADERS)
|
|
connection_header = resp.headers.get("connection")
|
|
if connection_header:
|
|
hop_by_hop.update(
|
|
header.strip().lower()
|
|
for header in connection_header.split(",")
|
|
if header.strip()
|
|
)
|
|
response_headers = {
|
|
key: value
|
|
for key, value in resp.headers.items()
|
|
if key.lower() not in hop_by_hop
|
|
}
|
|
|
|
return StreamingResponse(
|
|
content=_stream_backend_response(resp),
|
|
status_code=resp.status_code,
|
|
headers=response_headers,
|
|
)
|
|
except httpx.ConnectError as e:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Could not connect to the backend sandbox {endpoint}: {e}",
|
|
) from e
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500, detail=f"An internal error occurred in the proxy: {e}"
|
|
) from e
|
|
|
|
|
|
async def _fail_client_websocket(websocket: WebSocket, code: int, reason: str = "") -> None:
|
|
"""
|
|
Accept then close so the client receives a WebSocket close frame (not only HTTP failure).
|
|
|
|
Per ASGI/Starlette, closing before accept yields handshake-level errors instead of
|
|
a proper close code on the WebSocket connection.
|
|
"""
|
|
try:
|
|
await websocket.accept()
|
|
except RuntimeError:
|
|
pass
|
|
try:
|
|
await websocket.close(code=code, reason=reason[:123])
|
|
except RuntimeError:
|
|
pass
|
|
|
|
|
|
async def _relay_client_messages(
|
|
websocket: WebSocket,
|
|
backend: ClientConnection,
|
|
cancel_scope: anyio.CancelScope,
|
|
) -> None:
|
|
try:
|
|
while True:
|
|
message = await websocket.receive()
|
|
if message["type"] == "websocket.receive":
|
|
if message.get("text") is not None:
|
|
await backend.send(message["text"])
|
|
elif message.get("bytes") is not None:
|
|
await backend.send(message["bytes"])
|
|
elif message["type"] == "websocket.disconnect":
|
|
await backend.close(
|
|
code=message.get("code", status.WS_1000_NORMAL_CLOSURE),
|
|
reason=message.get("reason") or "",
|
|
)
|
|
return
|
|
except WebSocketDisconnect as exc:
|
|
await backend.close(code=exc.code, reason=getattr(exc, "reason", "") or "")
|
|
finally:
|
|
cancel_scope.cancel()
|
|
|
|
|
|
async def _relay_backend_messages(
|
|
websocket: WebSocket,
|
|
backend: ClientConnection,
|
|
cancel_scope: anyio.CancelScope,
|
|
) -> None:
|
|
try:
|
|
while True:
|
|
payload = await backend.recv()
|
|
if isinstance(payload, bytes):
|
|
await websocket.send_bytes(payload)
|
|
else:
|
|
await websocket.send_text(payload)
|
|
except websockets.ConnectionClosed as exc:
|
|
try:
|
|
await websocket.close(
|
|
code=exc.code or status.WS_1000_NORMAL_CLOSURE,
|
|
reason=exc.reason or "",
|
|
)
|
|
except RuntimeError:
|
|
pass
|
|
finally:
|
|
cancel_scope.cancel()
|
|
|
|
|
|
async def _proxy_websocket_request(
|
|
websocket: WebSocket,
|
|
sandbox_id: str,
|
|
port: int,
|
|
full_path: str,
|
|
) -> None:
|
|
if not await _authenticate_websocket_tenant(websocket):
|
|
return
|
|
|
|
try:
|
|
endpoint = lifecycle.sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)
|
|
except HTTPException as exc:
|
|
logger.warning(
|
|
"Rejecting websocket proxy request for sandbox=%s port=%s: %s",
|
|
sandbox_id,
|
|
port,
|
|
exc.detail,
|
|
)
|
|
await _fail_client_websocket(
|
|
websocket,
|
|
status.WS_1011_INTERNAL_ERROR,
|
|
str(exc.detail) if exc.detail else "",
|
|
)
|
|
return
|
|
|
|
try:
|
|
_verify_secure_access(endpoint, dict(websocket.headers))
|
|
except HTTPException:
|
|
await _fail_client_websocket(
|
|
websocket,
|
|
status.WS_1008_POLICY_VIOLATION,
|
|
"Missing or invalid secure-access token",
|
|
)
|
|
return
|
|
|
|
_schedule_proxy_renew(websocket, sandbox_id)
|
|
query_string = websocket.url.query or ""
|
|
target_url = _build_proxy_target_url(
|
|
endpoint,
|
|
full_path,
|
|
query_string,
|
|
websocket=True,
|
|
)
|
|
headers = _filter_proxy_headers(
|
|
dict(websocket.headers),
|
|
endpoint.headers,
|
|
extra_excluded=WEBSOCKET_HANDSHAKE_HEADERS,
|
|
connection_header=websocket.headers.get("connection"),
|
|
)
|
|
_set_forwarded_headers(headers, websocket)
|
|
subprotocols = list(websocket.scope.get("subprotocols", []))
|
|
raw_origin = websocket.headers.get("origin")
|
|
origin: Origin | None = Origin(raw_origin) if raw_origin else None
|
|
|
|
try:
|
|
# Do not inherit websockets' default max_size (1 MiB); proxy should not cap payloads.
|
|
async with websockets.connect(
|
|
target_url,
|
|
additional_headers=headers or None,
|
|
subprotocols=subprotocols or None,
|
|
origin=origin,
|
|
max_size=None,
|
|
) as backend:
|
|
await websocket.accept(subprotocol=backend.subprotocol)
|
|
async with anyio.create_task_group() as task_group:
|
|
task_group.start_soon(
|
|
_relay_client_messages,
|
|
websocket,
|
|
backend,
|
|
task_group.cancel_scope,
|
|
)
|
|
task_group.start_soon(
|
|
_relay_backend_messages,
|
|
websocket,
|
|
backend,
|
|
task_group.cancel_scope,
|
|
)
|
|
except websockets.InvalidStatus as exc:
|
|
logger.warning(
|
|
"Backend websocket handshake failed for sandbox=%s port=%s: %s",
|
|
sandbox_id,
|
|
port,
|
|
exc,
|
|
)
|
|
await _fail_client_websocket(websocket, status.WS_1008_POLICY_VIOLATION, "")
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Could not connect websocket proxy for sandbox=%s port=%s: %s",
|
|
sandbox_id,
|
|
port,
|
|
exc,
|
|
)
|
|
await _fail_client_websocket(websocket, status.WS_1011_INTERNAL_ERROR, "")
|
|
except Exception:
|
|
logger.exception(
|
|
"Unexpected websocket proxy failure for sandbox=%s port=%s",
|
|
sandbox_id,
|
|
port,
|
|
)
|
|
await _fail_client_websocket(websocket, status.WS_1011_INTERNAL_ERROR, "")
|
|
|
|
|
|
@router.api_route(
|
|
"/sandboxes/{sandbox_id}/proxy/{port}",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
)
|
|
async def proxy_sandbox_endpoint_root(
|
|
request: Request,
|
|
sandbox_id: str,
|
|
port: int,
|
|
):
|
|
"""Proxy HTTP requests targeting the backend root path."""
|
|
return await _proxy_http_request(request, sandbox_id, port, "")
|
|
|
|
|
|
@router.api_route(
|
|
"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
)
|
|
async def proxy_sandbox_endpoint_request(
|
|
request: Request,
|
|
sandbox_id: str,
|
|
port: int,
|
|
full_path: str,
|
|
):
|
|
"""Proxy HTTP requests to sandbox-backed services."""
|
|
return await _proxy_http_request(request, sandbox_id, port, full_path)
|
|
|
|
|
|
@router.websocket("/sandboxes/{sandbox_id}/proxy/{port}")
|
|
async def proxy_sandbox_endpoint_root_websocket(
|
|
websocket: WebSocket,
|
|
sandbox_id: str,
|
|
port: int,
|
|
):
|
|
"""Proxy WebSocket connections targeting the backend root path."""
|
|
await _proxy_websocket_request(websocket, sandbox_id, port, "")
|
|
|
|
|
|
@router.websocket("/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}")
|
|
async def proxy_sandbox_endpoint_request_websocket(
|
|
websocket: WebSocket,
|
|
sandbox_id: str,
|
|
port: int,
|
|
full_path: str,
|
|
):
|
|
"""Proxy WebSocket connections to sandbox-backed services."""
|
|
await _proxy_websocket_request(websocket, sandbox_id, port, full_path)
|