fix(webui): configure public websocket URL

This commit is contained in:
concertypin 2026-08-02 06:06:08 +09:00 committed by Xubin Ren
parent e318e21cad
commit cb2f9d0bbd
5 changed files with 75 additions and 1 deletions

View File

@ -103,6 +103,7 @@ direct TCP peer is the tunnel process and the assertion is non-empty:
"websocket": { "websocket": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
"publicWsUrl": "wss://nanobot.example.com/",
"trustedProxyAuth": { "trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"], "trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion" "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. non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
For this flow `/webui/bootstrap` returns connection metadata without a For this flow `/webui/bootstrap` returns connection metadata without a
bootstrap token or REST API token; the proxy assertion authorizes the WebSocket 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 by Cloudflare Access after authentication; routing/client metadata headers such
as `Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP` as `Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP`
are rejected as `assertionHeader` values. Nanobot trusts the assertion but does are rejected as `assertionHeader` values. Nanobot trusts the assertion but does

View File

@ -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. | | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
| `port` | int | `8765` | Listen port. | | `port` | int | `8765` | Listen port. |
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). | | `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. | | `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 ### Authentication
@ -310,6 +311,7 @@ user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`:
"channels": { "channels": {
"websocket": { "websocket": {
"host": "127.0.0.1", "host": "127.0.0.1",
"publicWsUrl": "wss://nanobot.example.com/",
"trustedProxyAuth": { "trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"], "trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion" "assertionHeader": "Cf-Access-Jwt-Assertion"

View File

@ -13,6 +13,7 @@ from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Self, TypeGuard, cast from typing import Any, Self, TypeGuard, cast
from urllib.parse import urlsplit, urlunsplit
from pydantic import Field, PrivateAttr, field_validator, model_validator from pydantic import Field, PrivateAttr, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve, unix_serve 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. blocking ``urllib`` or synchronous ``httpx`` from inside a coroutine.
- ``token_issue_secret``: If non-empty, token requests must send ``Authorization: Bearer <secret>`` or - ``token_issue_secret``: If non-empty, token requests must send ``Authorization: Bearer <secret>`` or
``X-Nanobot-Auth: <secret>``. ``X-Nanobot-Auth: <secret>``.
- ``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). - ``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. - 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 - ``media`` field in outbound messages contains local filesystem paths; remote clients need a
@ -186,6 +189,7 @@ class WebSocketConfig(Base):
port: int = 8765 port: int = 8765
unix_socket_path: str = "" unix_socket_path: str = ""
path: str = "/" path: str = "/"
public_ws_url: str = ""
token: str = "" token: str = ""
token_issue_path: str = "" token_issue_path: str = ""
token_issue_secret: str = "" token_issue_secret: str = ""
@ -234,6 +238,32 @@ class WebSocketConfig(Base):
raise ValueError('token_issue_path must start with "/"') raise ValueError('token_issue_path must start with "/"')
return _normalize_config_path(value) 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") @model_validator(mode="after")
def token_issue_path_differs_from_ws_path(self) -> Self: def token_issue_path_differs_from_ws_path(self) -> Self:
if not self.token_issue_path: if not self.token_issue_path:

View File

@ -3552,6 +3552,40 @@ def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
assert body["ws_url"] == "wss://nanobot.example/" 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: def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1") channel = _ch(bus, host="127.0.0.1")
resp = channel.gateway.http._handle_bootstrap(_REMOTE, _NO_HEADERS) resp = channel.gateway.http._handle_bootstrap(_REMOTE, _NO_HEADERS)

View File

@ -441,6 +441,8 @@ class GatewayHTTPHandler:
def _bootstrap_ws_url(self, request: Any) -> str: def _bootstrap_ws_url(self, request: Any) -> str:
headers = getattr(request, "headers", {}) or {} 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")) host = _safe_host_header(_case_insensitive_header(headers, "Host"))
if not host: if not host:
host = _host_for_url(self.config.host, self.config.port) host = _host_for_url(self.config.host, self.config.port)