Make the workspace editor reachable through the sub-path proxy

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>
This commit is contained in:
2026-08-09 11:26:17 +02:00
co-authored by Claude Opus 5
parent 91fac7fd67
commit c0742994cd
8 changed files with 380 additions and 79 deletions
@@ -281,4 +281,5 @@ async def editor_proxy_ws(
await websocket.close(code=1011)
return
activity.touch(instance["uid"])
await forward.proxy_ws(websocket, host, port, path)
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
await forward.proxy_ws(websocket, host, port, path, prefix=prefix)
+1 -2
View File
@@ -55,7 +55,6 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
if instance is None or not host or not port:
await websocket.close(code=1011)
return
await websocket.accept()
audit.record(
websocket,
"proxy.access",
@@ -67,4 +66,4 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
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, accepted=True)
await forward.proxy_ws(websocket, host, port, path, prefix=f"/p/{slug}")
+12 -5
View File
@@ -50,11 +50,18 @@ async def handle_http(request: Request, path: str) -> Response:
return Response("this workspace is suspended", status_code=403)
if not gateway or not port:
return Response("the tunnel has no reachable port", status_code=502)
response = await forward.proxy_http(request, gateway, port, path)
size = len(response.body) if hasattr(response, "body") and response.body else 0
activity.touch(instance["uid"], egress_bytes=size)
tunnels.record_hit(row["uid"], size)
return response
return await forward.proxy_http(
request,
gateway,
port,
path,
on_complete=lambda sent: record_traffic(instance["uid"], row["uid"], sent),
)
def record_traffic(instance_uid: str, tunnel_uid: str, sent: int) -> None:
activity.touch(instance_uid, egress_bytes=sent)
tunnels.record_hit(tunnel_uid, sent)
async def handle_ws(websocket: WebSocket, path: str) -> None: