diff --git a/docker-compose.yml b/docker-compose.yml index 1d87092f..f2e1e22a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: command: ["gateway"] restart: unless-stopped ports: - - 18790:18790 + - 127.0.0.1:18790:18790 - 8765:8765 deploy: resources: diff --git a/docs/deployment.md b/docs/deployment.md index 6c9f6a60..c749cc53 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,7 +13,7 @@ Check these once before Docker, systemd, or LaunchAgent: | Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable | | `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there | | Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot | -| Ports are planned | Gateway health defaults to `18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` | +| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` | | Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup | Restart the deployed process after editing `config.json`. Long-running processes read config at startup. @@ -54,6 +54,9 @@ Restart the deployed process after editing `config.json`. Long-running processes > ``` > > When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details. +> The gateway health route itself is intentionally minimal and unauthenticated. When the +> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any +> remotely monitored health endpoint behind a firewall or reverse proxy. ### Docker Compose @@ -93,7 +96,7 @@ docker run \ --security-opt apparmor=unconfined \ --security-opt seccomp=unconfined \ -v ~/.nanobot:/home/nanobot/.nanobot \ - -p 18790:18790 -p 8765:8765 \ + -p 127.0.0.1:18790:18790 -p 8765:8765 \ nanobot gateway # Or run a single command diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 04f81b33..aa018134 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -998,6 +998,35 @@ def _host_for_local_browser(host: str) -> str: return host +def _gateway_health_url(host: str, port: int) -> str: + """Return a health URL that can be opened from this device.""" + return f"http://{_host_for_local_browser(host)}:{port}/health" + + +def _gateway_health_bind_note(host: str) -> str: + """Describe a non-local bind without presenting it as a usable URL.""" + return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]" + + +_GATEWAY_HEALTH_MAX_CONNECTIONS = 64 +_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0 + + +def _print_gateway_health_endpoint(host: str, port: int) -> None: + """Print a usable health URL and make non-loopback binds explicit.""" + console.print( + f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}" + f"{_gateway_health_bind_note(host)}" + ) + if is_loopback_host(host): + return + + console.print( + "[yellow]Warning: the unauthenticated health endpoint is reachable beyond this device. " + f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]" + ) + + def _webui_bootstrap_secret(config: Config) -> str: ws_cfg = _webui_config_dict(config) return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip() @@ -1454,7 +1483,14 @@ def webui( console.print() console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]") - console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]") + gateway_health_url = _gateway_health_url( + runtime_config.gateway.host, + effective_gateway_port, + ) + console.print( + f"Gateway health: [cyan]{gateway_health_url}[/cyan]" + f"{_gateway_health_bind_note(runtime_config.gateway.host)}" + ) if no_open: console.print("[dim]Browser opening disabled by --no-open.[/dim]") if generated_bootstrap_secret: @@ -1916,42 +1952,52 @@ def _run_gateway( """Lightweight HTTP health endpoint on the gateway port.""" import json as _json + connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS) + async def handle(reader, writer): - try: - data = await asyncio.wait_for(reader.read(4096), timeout=5) - except (asyncio.TimeoutError, ConnectionError): + if connection_slots.locked(): writer.close() return - request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace") - method, path = "", "" - parts = request_line.split(" ") - if len(parts) >= 2: - method, path = parts[0], parts[1] + async with connection_slots: + try: + data = await asyncio.wait_for( + reader.read(4096), + timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS, + ) + request_line = data.split(b"\r\n", 1)[0].decode( + "utf-8", errors="replace", + ) + method, path = "", "" + parts = request_line.split(" ") + if len(parts) >= 2: + method, path = parts[0], parts[1] - if method == "GET" and path == "/health": - body = _json.dumps({"status": "ok"}) - resp = ( - f"HTTP/1.0 200 OK\r\n" - f"Content-Type: application/json\r\n" - f"Content-Length: {len(body)}\r\n" - f"\r\n{body}" - ) - else: - body = "Not Found" - resp = ( - f"HTTP/1.0 404 Not Found\r\n" - f"Content-Type: text/plain\r\n" - f"Content-Length: {len(body)}\r\n" - f"\r\n{body}" - ) + if method == "GET" and path == "/health": + body = _json.dumps({"status": "ok"}) + status = "200 OK" + content_type = "application/json" + else: + body = "Not Found" + status = "404 Not Found" + content_type = "text/plain" - writer.write(resp.encode()) - await writer.drain() - writer.close() + resp = ( + f"HTTP/1.0 {status}\r\n" + f"Content-Type: {content_type}\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n" + f"\r\n{body}" + ) + writer.write(resp.encode()) + await writer.drain() + except (asyncio.TimeoutError, ConnectionError): + pass + finally: + writer.close() server = await asyncio.start_server(handle, host, health_port) - console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health") + _print_gateway_health_endpoint(host, health_port) async with server: await server.serve_forever() # Register Dream system job (idempotent on restart) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 04b5efdd..fc8757f9 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -2763,11 +2763,23 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) assert "port 18792" in result.stdout +@pytest.mark.parametrize( + ("host", "display_url", "warns_about_public_bind"), + [ + ("127.0.0.1", "http://127.0.0.1:18791/health", False), + ("0.0.0.0", "http://127.0.0.1:18791/health", True), + ], +) def test_gateway_health_endpoint_binds_and_serves_expected_responses( - monkeypatch, tmp_path: Path + monkeypatch, + tmp_path: Path, + host: str, + display_url: str, + warns_about_public_bind: bool, ) -> None: config_file = _write_instance_config(tmp_path) config = Config() + config.gateway.host = host config.gateway.port = 18791 captured: dict[str, object] = {} @@ -2873,21 +2885,25 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses( result = runner.invoke(app, ["gateway", "--config", str(config_file)]) assert result.exit_code == 0 - assert captured["host"] == "127.0.0.1" + assert captured["host"] == host assert captured["port"] == 18791 - assert "Health endpoint: http://127.0.0.1:18791/health" in result.stdout + assert f"Health endpoint: {display_url}" in result.stdout + assert ("unauthenticated health endpoint" in result.stdout) is warns_about_public_bind + assert ("listening on 0.0.0.0" in result.stdout) is warns_about_public_bind + + health_handler = captured["handler"] + assert callable(health_handler) def _call_handler(path: str) -> tuple[str, _FakeWriter]: request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode() writer = _FakeWriter() - handler = captured["handler"] - assert callable(handler) - asyncio.run(handler(_FakeReader(request), writer)) + asyncio.run(health_handler(_FakeReader(request), writer)) return writer.output.decode(), writer root_response, root_writer = _call_handler("/") assert root_writer.closed is True assert "HTTP/1.0 404 Not Found" in root_response + assert "Connection: close" in root_response assert root_response.endswith("\r\n\r\nNot Found") health_response, health_writer = _call_handler("/health") @@ -2901,6 +2917,43 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses( assert "HTTP/1.0 404 Not Found" in missing_response assert missing_response.endswith("\r\n\r\nNot Found") + if host == "127.0.0.1": + async def _exercise_connection_limit() -> None: + release = asyncio.Event() + all_started = asyncio.Event() + started = 0 + + class _BlockingReader: + async def read(self, _size: int) -> bytes: + nonlocal started + started += 1 + if started == cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS: + all_started.set() + await release.wait() + return b"GET /health HTTP/1.1\r\n\r\n" + + active_writers = [ + _FakeWriter() for _ in range(cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS) + ] + active_tasks = [ + asyncio.create_task(health_handler(_BlockingReader(), writer)) + for writer in active_writers + ] + await asyncio.wait_for(all_started.wait(), timeout=1) + + overflow_writer = _FakeWriter() + await health_handler( + _FakeReader(b"GET /health HTTP/1.1\r\n\r\n"), + overflow_writer, + ) + assert overflow_writer.closed is True + assert overflow_writer.output == b"" + + release.set() + await asyncio.gather(*active_tasks) + + asyncio.run(_exercise_connection_limit()) + def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup( monkeypatch,