diff --git a/docs/deployment.md b/docs/deployment.md index cfdf40ccb..f9dbf65b5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -103,6 +103,7 @@ direct TCP peer is the tunnel process and the assertion is non-empty: "websocket": { "host": "127.0.0.1", "port": 8765, + "publicWsUrl": "wss://nanobot.example.com/", "trustedProxyAuth": { "trustedPeerCidrs": ["127.0.0.1/32", "::1/128"], "assertionHeader": "Cf-Access-Jwt-Assertion" @@ -116,7 +117,12 @@ This is two-part authorization: a trusted direct loopback peer **and** a non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass. For this flow `/webui/bootstrap` returns connection metadata without a bootstrap token or REST API token; the proxy assertion authorizes the WebSocket -handshake and REST requests directly. The assertion header must be generated +handshake and REST requests directly. + +Set `publicWsUrl` to the browser-facing `wss://` endpoint when the tunnel sends +the origin host header (such as `127.0.0.1:8765`); otherwise the WebUI could +attempt to open its WebSocket directly against the loopback address. +The assertion header must be generated by Cloudflare Access after authentication; routing/client metadata headers such as `Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP` are rejected as `assertionHeader` values. Nanobot trusts the assertion but does diff --git a/docs/websocket.md b/docs/websocket.md index 273d8536c..7dbc6d23c 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -216,6 +216,7 @@ All fields go under `channels.websocket` in `config.json`. | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. | | `port` | int | `8765` | Listen port. | | `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). | +| `publicWsUrl` | string | `""` | Exact public `ws://` or `wss://` endpoint returned by `/webui/bootstrap`. Set this when a reverse proxy forwards requests with an origin `Host` header (for example, `wss://claw.example.com/`); its path must match `path`. | | `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB – 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. | ### Authentication @@ -310,6 +311,7 @@ user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`: "channels": { "websocket": { "host": "127.0.0.1", + "publicWsUrl": "wss://nanobot.example.com/", "trustedProxyAuth": { "trustedPeerCidrs": ["127.0.0.1/32", "::1/128"], "assertionHeader": "Cf-Access-Jwt-Assertion" diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index b0fb7390b..c65ce0537 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -13,6 +13,7 @@ from collections.abc import Callable from contextlib import suppress from pathlib import Path from typing import Any, Self, TypeGuard, cast +from urllib.parse import urlsplit, urlunsplit from pydantic import Field, PrivateAttr, field_validator, model_validator from websockets.asyncio.server import ServerConnection, serve, unix_serve @@ -175,6 +176,8 @@ class WebSocketConfig(Base): blocking ``urllib`` or synchronous ``httpx`` from inside a coroutine. - ``token_issue_secret``: If non-empty, token requests must send ``Authorization: Bearer `` or ``X-Nanobot-Auth: ``. + - ``public_ws_url``: Optional public WebSocket endpoint returned by WebUI bootstrap instead of + deriving one from proxy request headers. Its path must match ``path``. - ``websocket_requires_token``: If True, the handshake must include a valid token (static or issued and not expired). - Each connection has its own session: a unique ``chat_id`` maps to the agent session internally. - ``media`` field in outbound messages contains local filesystem paths; remote clients need a @@ -186,6 +189,7 @@ class WebSocketConfig(Base): port: int = 8765 unix_socket_path: str = "" path: str = "/" + public_ws_url: str = "" token: str = "" token_issue_path: str = "" token_issue_secret: str = "" @@ -234,6 +238,32 @@ class WebSocketConfig(Base): raise ValueError('token_issue_path must start with "/"') return _normalize_config_path(value) + @field_validator("public_ws_url") + @classmethod + def public_ws_url_format(cls, value: str) -> str: + value = value.strip() + if not value: + return "" + parsed = urlsplit(value) + if ( + parsed.scheme not in {"ws", "wss"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("public_ws_url must be an absolute ws:// or wss:// URL without credentials") + return urlunsplit( + (parsed.scheme, parsed.netloc, _normalize_config_path(parsed.path or "/"), "", "") + ) + + @model_validator(mode="after") + def public_ws_url_matches_path(self) -> Self: + if self.public_ws_url and urlsplit(self.public_ws_url).path != _normalize_config_path(self.path): + raise ValueError("public_ws_url path must match path") + return self + @model_validator(mode="after") def token_issue_path_differs_from_ws_path(self) -> Self: if not self.token_issue_path: diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 5d073b7b0..7170b6b83 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -3552,6 +3552,40 @@ def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None: assert body["ws_url"] == "wss://nanobot.example/" +def test_bootstrap_ws_url_uses_configured_public_url(bus: MagicMock) -> None: + channel = _ch( + bus, + host="127.0.0.1", + port=29931, + tokenIssueSecret="s3cret", + publicWsUrl="wss://claw.wasapi.xyz/", + ) + resp = channel.gateway.http._handle_bootstrap( + _LOCAL, + _FakeReq( + { + "Authorization": "Bearer s3cret", + "Host": "127.0.0.1:29931", + "X-Forwarded-Proto": "https", + } + ), + ) + assert resp.status_code == 200 + assert json.loads(resp.body)["ws_url"] == "wss://claw.wasapi.xyz/" + + +def test_public_ws_url_must_match_configured_path() -> None: + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="public_ws_url path must match path"): + WebSocketConfig.model_validate( + { + "path": "/socket", + "publicWsUrl": "wss://claw.wasapi.xyz/", + } + ) + + def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") resp = channel.gateway.http._handle_bootstrap(_REMOTE, _NO_HEADERS) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 60edee929..56f3a93dc 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -441,6 +441,8 @@ class GatewayHTTPHandler: def _bootstrap_ws_url(self, request: Any) -> str: headers = getattr(request, "headers", {}) or {} + if self.config.public_ws_url: + return self.config.public_ws_url host = _safe_host_header(_case_insensitive_header(headers, "Host")) if not host: host = _host_for_url(self.config.host, self.config.port)