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:
+15
-2
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user