code-server runs authenticateOrigin on every websocket and resolves the request host as Forwarded, then X-Forwarded-Host, then Host. The forward core put the public host into additional_headers, but the websockets client already writes its own Host for the real TCP target and Headers appends, so the handshake carried two Host lines; Node keeps the first (the internal gateway:port), the origin check failed, and code-server answered 403. Because the browser socket was accepted before the upstream was dialled, that surfaced as a 101 followed by 1011 and the editor died on "the workbench failed to connect to the server". Dialling first and carrying the public host in the connect URI fixes both planes. The two header builders that had drifted apart are now one core, so a websocket carries the same client and forwarded headers as an HTTP request. Responses stream instead of buffering whole, which is what makes a large tunnel download cost constant memory and lets SSE work; byte accounting moved onto the completion callback. Subprotocols negotiate, the upstream client is reused across requests, and the path and query are forwarded byte-exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
70 lines
2.4 KiB
Python
70 lines
2.4 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, WebSocket
|
|
from starlette.responses import Response
|
|
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.containers import api, forward, store
|
|
from devplacepy.utils import not_found
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
METHODS = forward.METHODS
|
|
|
|
|
|
def _resolve(slug: str):
|
|
instance = store.find_instance_by_ingress(slug)
|
|
if not instance or instance.get("status") != store.ST_RUNNING:
|
|
return None, None, None
|
|
host, port = api.proxy_target(instance)
|
|
return instance, host, port
|
|
|
|
|
|
@router.api_route("/{slug}", methods=METHODS)
|
|
@router.api_route("/{slug}/{path:path}", methods=METHODS)
|
|
async def proxy_http(request: Request, slug: str, path: str = ""):
|
|
instance, host, port = _resolve(slug)
|
|
if instance is None:
|
|
raise not_found("No running container is published at this address")
|
|
if not host or not port:
|
|
return Response(
|
|
"the published container has no reachable port", status_code=502
|
|
)
|
|
audit.record(
|
|
request,
|
|
"proxy.access",
|
|
target_type="instance",
|
|
target_uid=instance["uid"],
|
|
target_label=instance.get("name"),
|
|
metadata={"slug": slug, "path": path, "method": request.method},
|
|
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
|
|
links=[audit.instance(instance["uid"], instance.get("name"))],
|
|
)
|
|
return await forward.proxy_http(
|
|
request, host, port, path, prefix=f"/p/{slug}", timeout=60.0
|
|
)
|
|
|
|
|
|
@router.websocket("/{slug}")
|
|
@router.websocket("/{slug}/{path:path}")
|
|
async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
|
instance, host, port = _resolve(slug)
|
|
if instance is None or not host or not port:
|
|
await websocket.close(code=1011)
|
|
return
|
|
audit.record(
|
|
websocket,
|
|
"proxy.access",
|
|
user=None,
|
|
target_type="instance",
|
|
target_uid=instance["uid"],
|
|
target_label=instance.get("name"),
|
|
metadata={"slug": slug, "path": path, "protocol": "websocket"},
|
|
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
|
|
links=[audit.instance(instance["uid"], instance.get("name"))],
|
|
)
|
|
await forward.proxy_ws(websocket, host, port, path, prefix=f"/p/{slug}")
|