diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 62e67a5b7..7a1e2a06f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -607,7 +607,9 @@ class WebSocketChannel(BaseChannel): self._api_tokens.pop(token_key, None) def _handle_webui_bootstrap(self, connection: Any) -> Response: - if not _is_localhost(connection): + if self.config.host not in ("0.0.0.0", "::") and not _is_localhost( + connection, + ): return _http_error(403, "webui bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 51fd50f4a..e09611956 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -379,3 +379,41 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> headers = {"Authorization": "Bearer live"} assert channel._check_api_token(_LiveReq()) is True + + +class _FakeConn: + """Minimal connection stub with a configurable remote_address.""" + + def __init__(self, remote_address: tuple[str, int]): + self.remote_address = remote_address + + def respond(self, status: int, body: str) -> Any: + from websockets.http11 import Response + + return Response(status=status, body=body.encode()) + + +def test_bootstrap_rejects_non_localhost_by_default(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 403 + + +def test_bootstrap_allows_non_localhost_when_host_is_wildcard(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + assert body["ws_path"] == "/" + + +def test_bootstrap_allows_non_localhost_when_host_is_ipv6_wildcard( + bus: MagicMock, +) -> None: + channel = _ch(bus, host="::") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 200 diff --git a/webui/README.md b/webui/README.md index 602b179e7..056fe85f4 100644 --- a/webui/README.md +++ b/webui/README.md @@ -72,6 +72,26 @@ If your gateway listens on a non-default port, point the dev server at it: NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ``` +### Access from another device (LAN) + +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` in `~/.nanobot/config.json`: + +```json +{ + "channels": { + "websocket": { + "enabled": true, + "host": "0.0.0.0", + "port": 8765 + } + } +} +``` + +Then open `http://:8765` on the other device. When `host` is `"0.0.0.0"`, the bootstrap endpoint accepts requests from any source instead of restricting to localhost. + +> **Note:** This exposes the gateway to all interfaces. Only use on trusted networks. + ## Build for packaged runtime ```bash