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
+15 -2
View File
@@ -307,6 +307,9 @@ async def lifespan(app: FastAPI):
flush_visits()
await service_manager.shutdown_all()
await background.stop()
from devplacepy.services.containers import forward
await forward.close_client()
app = FastAPI(
@@ -646,14 +649,24 @@ def _terms_exempt(path: str) -> bool:
async def terms_acceptance_gate(request: Request, call_next):
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
return await call_next(request)
from devplacepy.routers.auth.terms import needs_acceptance
from devplacepy.routers.auth.terms import (
TERMS_ACCEPTANCE_CODE,
current_terms_version,
needs_acceptance,
)
user = get_current_user(request)
if not needs_acceptance(user):
return await call_next(request)
message = "Accept the updated Terms of Service to continue."
if wants_json(request):
return json_error(403, message, redirect="/auth/accept-terms")
return json_error(
403,
message,
code=TERMS_ACCEPTANCE_CODE,
redirect="/auth/accept-terms",
terms_version=current_terms_version(),
)
return RedirectResponse(url="/auth/accept-terms", status_code=303)
@@ -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:
+55 -2
View File
@@ -222,8 +222,61 @@ WebSocket, so a tunnel host can never render the application. molohttp's `HostIn
**exactly one label**, which is why the pattern is `{port}-{name}`, never `{port}.{name}`.
**One forwarding core.** `forward.py` owns header filtering, prefix stripping, `Location` rewriting,
`<base>` injection and the bidirectional WS pump. `/p/{slug}`, the editor route and the tunnel route
all call it. Never write a second proxy.
`<base>` injection, response streaming and the bidirectional WS pump. `/p/{slug}`, the editor route
and the tunnel route all call it. Never write a second proxy, and never let the two planes drift:
**both build their headers from the single `base_headers` core**, so anything a tunnelled app sees
over HTTP (`X-Real-IP`, `X-Forwarded-For`, `Accept-Language`, custom auth headers) it also sees over
a websocket. `forward_headers` adds only what is HTTP-specific (`Host`, `Accept-Encoding: identity`
so the internal hop is never compressed); `ws_headers` drops only `WS_HANDSHAKE_HEADERS`, which the
`websockets` client regenerates itself. Both planes therefore send the same `Host` (the public one),
the same `X-Forwarded-Host`/`-Proto`/`-Prefix`, and the same `X-Script-Name`.
**The websocket handshake carries exactly ONE `Host`, and it is the public one (load-bearing).**
Never put `Host` in `additional_headers`: the `websockets` client already writes its own for the real
TCP target and `Headers.update` *appends*, so a second one produces a handshake with two `Host`
lines. Node keeps the FIRST (the internal `gateway:port`) and Go's `net/http` rejects the request
outright. The fix is `websockets.connect(uri, host=..., port=...)` - the public host goes in the URI
(so it becomes the single `Host` header) while the connection still dials the container. code-server
resolves the request host as `Forwarded` -> `X-Forwarded-Host` -> `Host` and runs
`authenticateOrigin` on EVERY websocket (`wsRouter.ws(/.*/, ensureOrigin, ...)`), so with only the
losing duplicate it compared the browser's `Origin` (`devplace.net`) against the internal host and
answered **403**, and the editor died on *"The workbench failed to connect to the server (Error: Time
limit reached)"* - a blank page under the whole
`/projects/{slug}/containers/instances/{uid}/code/` sub-path while its HTTP assets loaded fine
(`forward_headers` already sent `X-Forwarded-Host`, which is why only the websocket plane broke).
This hit the tunnel plane identically. Reproduce with the real image, not a mock: `docker run ppy
code-server --auth none` and replay the header set - the origin check runs before authentication.
**Dial upstream BEFORE accepting the browser socket.** `proxy_ws` connects first and only then calls
`websocket.accept(subprotocol=upstream.subprotocol)`. Accepting first turns every upstream refusal
into a phantom `101` followed by an immediate `1011`, which is exactly what made the 403 above
present as an opaque client-side timeout instead of a handshake failure; it also makes it impossible
to echo the negotiated subprotocol, because the answer is not known yet. Subprotocols travel through
`subprotocols=` (never as a forwarded header) and come back on the `accept`.
**Responses stream; only the `<base>`-injected HTML is buffered.** `proxy_http` sends with
`stream=True` and returns a `StreamingResponse` over `aiter_raw()`, passing `content-length` and
`content-encoding` through untouched (`RESPONSE_HOP_HEADERS` strips hop-by-hop only), so a large
download through a tunnel costs a constant few hundred KiB of worker memory instead of its full size,
and SSE works. The HTML branch must buffer because injection needs the whole body, and it therefore
drops `content-length`/`content-encoding` and lets Starlette recompute. Byte accounting rides the
`on_complete(sent)` callback fired when the stream ends - `routers/tunnel.py` uses it for
`activity.touch` + `tunnels.record_hit`, which is ONE `touch` per request (`activity` accumulates
while throttled, so a second call would double the request counter). **Request bodies stay
buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would
force chunked encoding onto arbitrary upstream apps.
**One keep-alive client, closed on shutdown.** `forward.client()` is a lazily-created module-level
`httpx.AsyncClient` with `httpx.Limits` (the `ChromeStealthClient` pattern), closed by
`forward.close_client()` in the `main.py` lifespan. A client per request meant a fresh pool and TCP
handshake for every one of the workbench's hundreds of assets.
**Path and query are forwarded byte-exactly.** The route-matched path is `quote()`d before it is
interpolated (a filename containing `#` or a space otherwise truncates or corrupts the upstream URL),
and the query comes from `raw_query(connection)` = the raw ASGI `scope["query_string"]`, NEVER
`request.url.query`/`request.query_params`. Starlette rebuilds `.url` by string-joining path and
query, so a literal `#` in the path makes the reconstructed URL treat the query as a fragment and
`.url.query` silently returns empty - which would drop code-server's `?reconnectionToken=`.
**Editor persistence.** code-server's user-data and extensions live in
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
+187 -65
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Callable
from urllib.parse import quote
import httpx
import websockets
from fastapi import Request, WebSocket
from starlette.responses import Response
from starlette.responses import Response, StreamingResponse
logger = logging.getLogger(__name__)
@@ -26,40 +28,110 @@ HOP_HEADERS = {
"content-encoding",
}
RESPONSE_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
WS_HANDSHAKE_HEADERS = {
"sec-websocket-key",
"sec-websocket-version",
"sec-websocket-extensions",
"sec-websocket-protocol",
}
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
SCHEME_PROTO = {"http": "http", "https": "https", "ws": "http", "wss": "https"}
DEFAULT_TIMEOUT = 300.0
OPEN_TIMEOUT = 10.0
MAX_CONNECTIONS = 200
MAX_KEEPALIVE_CONNECTIONS = 50
_client: httpx.AsyncClient | None = None
def forward_headers(request: Request, prefix: str = "") -> dict:
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
def client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
follow_redirects=False,
timeout=DEFAULT_TIMEOUT,
limits=httpx.Limits(
max_connections=MAX_CONNECTIONS,
max_keepalive_connections=MAX_KEEPALIVE_CONNECTIONS,
),
)
return _client
async def close_client() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
def public_host(connection) -> str:
return connection.headers.get("host", "") or connection.url.hostname or ""
def raw_query(connection) -> str:
return connection.scope.get("query_string", b"").decode("latin-1")
def base_headers(connection, prefix: str, drop: set[str]) -> dict:
headers = {
name: value
for name, value in connection.headers.items()
if name.lower() not in HOP_HEADERS and name.lower() not in drop
}
if prefix:
headers["X-Forwarded-Prefix"] = prefix
headers["X-Script-Name"] = prefix
origin_host = request.headers.get("host", request.url.hostname or "")
headers["X-Forwarded-Host"] = origin_host
origin_host = public_host(connection)
if origin_host:
headers["X-Forwarded-Host"] = origin_host
headers["X-Forwarded-Proto"] = connection.headers.get(
"x-forwarded-proto", SCHEME_PROTO.get(connection.url.scheme, "http")
)
return headers
def forward_headers(request: Request, prefix: str = "") -> dict:
headers = base_headers(request, prefix, set())
origin_host = public_host(request)
if origin_host:
headers["Host"] = origin_host
headers["X-Forwarded-Proto"] = request.headers.get(
"x-forwarded-proto", request.url.scheme
)
headers["Accept-Encoding"] = "identity"
return headers
WS_FORWARD_HEADERS = ("cookie", "authorization", "user-agent", "origin")
def ws_headers(websocket: WebSocket, prefix: str = "") -> dict:
return base_headers(websocket, prefix, WS_HANDSHAKE_HEADERS)
def ws_headers(websocket) -> dict:
headers = {
name: websocket.headers[name]
for name in WS_FORWARD_HEADERS
if name in websocket.headers
}
origin_host = websocket.headers.get("host", "")
if origin_host:
headers["Host"] = origin_host
return headers
def ws_subprotocols(websocket: WebSocket) -> list[str] | None:
offered = [
value.strip()
for value in websocket.headers.get("sec-websocket-protocol", "").split(",")
if value.strip()
]
return offered or None
def upstream_url(scheme: str, authority: str, path: str, query: str) -> str:
url = f"{scheme}://{authority}/{quote(path)}"
if query:
url = f"{url}?{query}"
return url
def inject_base(body: bytes, prefix: str) -> bytes:
@@ -86,6 +158,37 @@ def rewrite_location(value: str, prefix: str) -> str:
return value
def response_headers(upstream: httpx.Response, prefix: str) -> dict:
headers = {
name: value
for name, value in upstream.headers.items()
if name.lower() not in RESPONSE_HOP_HEADERS and name.lower() != "set-cookie"
}
if "location" in headers:
headers["location"] = rewrite_location(headers["location"], prefix)
return headers
def apply_cookies(response: Response, upstream: httpx.Response) -> Response:
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
return response
async def stream_body(
upstream: httpx.Response, on_complete: Callable[[int], None] | None
) -> AsyncIterator[bytes]:
sent = 0
try:
async for chunk in upstream.aiter_raw():
sent += len(chunk)
yield chunk
finally:
await upstream.aclose()
if on_complete is not None:
on_complete(sent)
async def proxy_http(
request: Request,
host: str,
@@ -95,67 +198,86 @@ async def proxy_http(
prefix: str = "",
timeout: float = DEFAULT_TIMEOUT,
rewrite_html: bool = True,
on_complete: Callable[[int], None] | None = None,
) -> Response:
url = f"http://{host}:{port}/{path}"
headers = forward_headers(request, prefix)
body = await request.body()
upstream_request = client().build_request(
request.method,
upstream_url("http", f"{host}:{port}", path, raw_query(request)),
headers=forward_headers(request, prefix),
content=await request.body(),
timeout=timeout,
)
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=False
) as client:
upstream = await client.request(
request.method,
url,
params=request.query_params,
headers=headers,
content=body,
)
upstream = await client().send(upstream_request, stream=True)
except httpx.HTTPError as error:
return Response(f"upstream error: {error}", status_code=502)
out_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
}
if "location" in out_headers:
out_headers["location"] = rewrite_location(out_headers["location"], prefix)
content_type = upstream.headers.get("content-type", "")
content = upstream.content
headers = response_headers(upstream, prefix)
if prefix and rewrite_html and "text/html" in content_type.lower():
content = inject_base(content, prefix)
response = Response(
content=content,
status_code=upstream.status_code,
headers=out_headers,
media_type=content_type or None,
body = await upstream.aread()
await upstream.aclose()
content = inject_base(body, prefix)
headers.pop("content-length", None)
headers.pop("content-encoding", None)
if on_complete is not None:
on_complete(len(content))
return apply_cookies(
Response(
content=content,
status_code=upstream.status_code,
headers=headers,
media_type=content_type or None,
),
upstream,
)
return apply_cookies(
StreamingResponse(
stream_body(upstream, on_complete),
status_code=upstream.status_code,
headers=headers,
media_type=content_type or None,
),
upstream,
)
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
return response
async def close_quietly(websocket: WebSocket) -> None:
try:
await websocket.close(code=1011)
except Exception:
pass
async def proxy_ws(
websocket: WebSocket, host: str, port: int, path: str, *, accepted: bool = False
websocket: WebSocket,
host: str,
port: int,
path: str,
*,
prefix: str = "",
timeout: float = OPEN_TIMEOUT,
) -> None:
upstream_url = f"ws://{host}:{port}/{path}"
if websocket.url.query:
upstream_url += f"?{websocket.url.query}"
if not accepted:
await websocket.accept()
authority = public_host(websocket) or f"{host}:{port}"
target = upstream_url("ws", authority, path, raw_query(websocket))
try:
async with websockets.connect(
upstream_url,
open_timeout=10,
upstream = await websockets.connect(
target,
host=host,
port=port,
open_timeout=timeout,
max_size=None,
additional_headers=ws_headers(websocket),
) as upstream:
await pump(websocket, upstream)
additional_headers=ws_headers(websocket, prefix),
subprotocols=ws_subprotocols(websocket),
)
except Exception as error:
logger.debug("ws proxy to %s:%s failed: %s", host, port, error)
try:
await websocket.close(code=1011)
except Exception:
pass
await close_quietly(websocket)
return
try:
await websocket.accept(subprotocol=upstream.subprotocol)
await pump(websocket, upstream)
finally:
await upstream.close()
async def pump(client_ws: WebSocket, upstream) -> None: