mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
fix(webui): configure public websocket URL
This commit is contained in:
parent
e318e21cad
commit
cb2f9d0bbd
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 <secret>`` or
|
||||
``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).
|
||||
- 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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user