forked from retoor/devplacepy
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:
@@ -7,7 +7,9 @@ from tests.conftest import BASE_URL, run_async
|
||||
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import api, store, runtime
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
from devplacepy.services.containers import api, forward, store, runtime
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
||||
from devplacepy.services.containers.backend.fake import FakeBackend
|
||||
@@ -401,3 +403,107 @@ def test_bidirectional_sync_newer_wins(env, tmp_path):
|
||||
assert (workspace / "shared.txt").read_text() == "from project\n"
|
||||
imported = project_files.read_file(pid, "fromfs.txt")
|
||||
assert imported["content"] == "from fs\n"
|
||||
|
||||
|
||||
|
||||
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
|
||||
return {
|
||||
"type": kind,
|
||||
"scheme": scheme,
|
||||
"server": ("devplace.net", 443),
|
||||
"path": "/projects/demo/containers/instances/abc/code/stable-1",
|
||||
"query_string": query.encode(),
|
||||
"headers": [
|
||||
(name.encode(), value.encode()) for name, value in headers.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _proxy_websocket(headers: dict, scheme: str = "wss", query: str = "") -> WebSocket:
|
||||
return WebSocket(_proxy_scope("websocket", headers, scheme, query), None, None)
|
||||
|
||||
|
||||
def _proxy_request(headers: dict, scheme: str = "https", query: str = "") -> Request:
|
||||
return Request(_proxy_scope("http", headers, scheme, query))
|
||||
|
||||
|
||||
def test_ws_headers_send_the_public_host_only_as_x_forwarded_host():
|
||||
headers = forward.ws_headers(
|
||||
_proxy_websocket(
|
||||
{
|
||||
"host": "devplace.net",
|
||||
"origin": "https://devplace.net",
|
||||
"cookie": "session=abc",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert headers["X-Forwarded-Host"] == "devplace.net"
|
||||
assert headers["origin"] == "https://devplace.net"
|
||||
assert headers["cookie"] == "session=abc"
|
||||
assert not [name for name in headers if name.lower() == "host"]
|
||||
|
||||
|
||||
def test_ws_headers_drop_only_the_handshake_headers_the_client_regenerates():
|
||||
headers = forward.ws_headers(
|
||||
_proxy_websocket(
|
||||
{
|
||||
"host": "devplace.net",
|
||||
"x-real-ip": "203.0.113.7",
|
||||
"accept-language": "nl-NL",
|
||||
"sec-websocket-key": "should-not-survive",
|
||||
"sec-websocket-version": "13",
|
||||
"sec-websocket-extensions": "permessage-deflate",
|
||||
"sec-websocket-protocol": "v1",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert headers["x-real-ip"] == "203.0.113.7"
|
||||
assert headers["accept-language"] == "nl-NL"
|
||||
for name in forward.WS_HANDSHAKE_HEADERS:
|
||||
assert name not in headers
|
||||
|
||||
|
||||
def test_ws_headers_derive_the_forwarded_proto_from_the_socket_scheme():
|
||||
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}))[
|
||||
"X-Forwarded-Proto"
|
||||
] == "https"
|
||||
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}, scheme="ws"))[
|
||||
"X-Forwarded-Proto"
|
||||
] == "http"
|
||||
assert forward.ws_headers(
|
||||
_proxy_websocket({"host": "d.net", "x-forwarded-proto": "https"}, scheme="ws")
|
||||
)["X-Forwarded-Proto"] == "https"
|
||||
|
||||
|
||||
def test_forward_headers_send_the_public_host_and_the_prefix():
|
||||
headers = forward.forward_headers(
|
||||
_proxy_request({"host": "devplace.net", "x-real-ip": "203.0.113.7"}),
|
||||
prefix="/p/demo",
|
||||
)
|
||||
assert headers["Host"] == "devplace.net"
|
||||
assert headers["X-Forwarded-Host"] == "devplace.net"
|
||||
assert headers["X-Forwarded-Prefix"] == "/p/demo"
|
||||
assert headers["X-Script-Name"] == "/p/demo"
|
||||
assert headers["x-real-ip"] == "203.0.113.7"
|
||||
|
||||
|
||||
def test_ws_subprotocols_are_parsed_for_negotiation():
|
||||
assert forward.ws_subprotocols(_proxy_websocket({"host": "d.net"})) is None
|
||||
assert forward.ws_subprotocols(
|
||||
_proxy_websocket({"host": "d.net", "sec-websocket-protocol": "v2, v1"})
|
||||
) == ["v2", "v1"]
|
||||
|
||||
|
||||
def test_upstream_url_encodes_the_path_and_keeps_the_query_verbatim():
|
||||
assert (
|
||||
forward.upstream_url("http", "h:1", "dir/a b#c", "keep=1")
|
||||
== "http://h:1/dir/a%20b%23c?keep=1"
|
||||
)
|
||||
assert forward.upstream_url("ws", "h:1", "", "") == "ws://h:1/"
|
||||
|
||||
|
||||
def test_raw_query_survives_a_hash_in_the_path():
|
||||
request = _proxy_request({"host": "d.net"}, query="keep=1")
|
||||
request.scope["path"] = "/weird/a b#c"
|
||||
assert forward.raw_query(request) == "keep=1"
|
||||
assert request.url.query == ""
|
||||
|
||||
Reference in New Issue
Block a user