diff --git a/backend/fluksio/cloud/connector.py b/backend/fluksio/cloud/connector.py index 0135c05..a6855bd 100644 --- a/backend/fluksio/cloud/connector.py +++ b/backend/fluksio/cloud/connector.py @@ -191,9 +191,12 @@ class CloudConnector: import websockets attached = False - url = f"{config.ws_url}?token={config.token}" + # A header rather than `?token=`, since a URL ends up in access logs. + # A hub from before the header reads only the query: upgrade the hub + # before its instances. async with websockets.connect( - url, + config.ws_url, + additional_headers={"Authorization": f"Bearer {config.token}"}, max_size=MAX_WS_MESSAGE, ping_interval=PING_INTERVAL_S, ping_timeout=PING_TIMEOUT_S, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f7a19a3..18d7911 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -74,8 +74,9 @@ dependencies = [ "textual>=1.0", # That dashboard subscribes to the engine's event bus over the same socket # the browser uses. It arrives under `fastapi[standard]` either way, and is - # named here because this code imports it. - "websockets>=13", + # named here because this code imports it. 14 is where the asyncio client + # the portal link dials with takes `additional_headers`. + "websockets>=14", # The encrypted-payload half of web push (RFC 8291). It brings only # cryptography, which is already here. `pywebpush` would do the signing and # the request too, at the cost of `requests` *and* `aiohttp` — two HTTP diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 83ced0a..76e1665 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -611,3 +611,33 @@ def test_the_tunnel_opens_the_live_socket_with_a_ticket( assert asyncio.run(opened())[0] == "ws_open_ok" # Used once: the same ticket again is refused. assert asyncio.run(opened()) == ["ws_close"] + + +@pytest.mark.anyio +async def test_the_link_sends_its_credential_as_a_header() -> None: + """Not in the URL, which access logs keep.""" + import json + from types import SimpleNamespace + + from websockets.asyncio.server import ServerConnection, serve + + from fluksio.cloud.connector import CloudConnector + + seen: dict[str, str | None] = {} + + async def hub(socket: ServerConnection) -> None: + assert socket.request is not None + seen["path"] = socket.request.path + seen["auth"] = socket.request.headers.get("Authorization") + await socket.recv() + await socket.send(json.dumps({"op": "refused", "reason": "test hub"})) + + async with serve(hub, "127.0.0.1", 0) as server: + port = next(iter(server.sockets)).getsockname()[1] + config = SimpleNamespace( + ws_url=f"ws://127.0.0.1:{port}/api/v1/tunnel/attach", token="t0k" + ) + with pytest.raises(RuntimeError, match="test hub"): + await CloudConnector(Mock())._session(config) # type: ignore[arg-type] + + assert seen == {"path": "/api/v1/tunnel/attach", "auth": "Bearer t0k"} diff --git a/docs/interface/portal.md b/docs/interface/portal.md index 73453ca..251ebea 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -49,6 +49,12 @@ Once enrolled, the instance appears under **Instances** with its status, when it was last seen and its version. **Open** takes you to its dashboard at `${DOMAIN}/i/{instance-id}`. +**Upgrade a self-hosted portal before its instances.** An instance dials the +tunnel with its credential in an `Authorization` header, where it stays out of +access logs, and a portal from before that reads it only from the URL. The +portal still takes the old `?token=` for one release, so older instances keep +attaching meanwhile. + ## What the portal can and cannot do The portal holds one credential for your instance and proxies requests down diff --git a/uv.lock b/uv.lock index eb11543..10e0f43 100644 --- a/uv.lock +++ b/uv.lock @@ -999,7 +999,7 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, { name = "textual", specifier = ">=1.0" }, { name = "uv", specifier = ">=0.5" }, - { name = "websockets", specifier = ">=13" }, + { name = "websockets", specifier = ">=14" }, ] provides-extras = ["parquet", "server"]