fix(gateway): harden health endpoint exposure
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@ services:
|
|||||||
command: ["gateway"]
|
command: ["gateway"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 18790:18790
|
- 127.0.0.1:18790:18790
|
||||||
- 8765:8765
|
- 8765:8765
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+5
-2
@@ -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 |
|
| 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 |
|
| `~/.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 |
|
| 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 |
|
| 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.
|
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.
|
> 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
|
### Docker Compose
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ docker run \
|
|||||||
--security-opt apparmor=unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
--security-opt seccomp=unconfined \
|
--security-opt seccomp=unconfined \
|
||||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
-p 18790:18790 -p 8765:8765 \
|
-p 127.0.0.1:18790:18790 -p 8765:8765 \
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
|
|
||||||
# Or run a single command
|
# Or run a single command
|
||||||
|
|||||||
+75
-29
@@ -998,6 +998,35 @@ def _host_for_local_browser(host: str) -> str:
|
|||||||
return host
|
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:
|
def _webui_bootstrap_secret(config: Config) -> str:
|
||||||
ws_cfg = _webui_config_dict(config)
|
ws_cfg = _webui_config_dict(config)
|
||||||
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
||||||
@@ -1454,7 +1483,14 @@ def webui(
|
|||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
|
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:
|
if no_open:
|
||||||
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
||||||
if generated_bootstrap_secret:
|
if generated_bootstrap_secret:
|
||||||
@@ -1916,42 +1952,52 @@ def _run_gateway(
|
|||||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
|
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
|
||||||
|
|
||||||
async def handle(reader, writer):
|
async def handle(reader, writer):
|
||||||
try:
|
if connection_slots.locked():
|
||||||
data = await asyncio.wait_for(reader.read(4096), timeout=5)
|
|
||||||
except (asyncio.TimeoutError, ConnectionError):
|
|
||||||
writer.close()
|
writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace")
|
async with connection_slots:
|
||||||
method, path = "", ""
|
try:
|
||||||
parts = request_line.split(" ")
|
data = await asyncio.wait_for(
|
||||||
if len(parts) >= 2:
|
reader.read(4096),
|
||||||
method, path = parts[0], parts[1]
|
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":
|
if method == "GET" and path == "/health":
|
||||||
body = _json.dumps({"status": "ok"})
|
body = _json.dumps({"status": "ok"})
|
||||||
resp = (
|
status = "200 OK"
|
||||||
f"HTTP/1.0 200 OK\r\n"
|
content_type = "application/json"
|
||||||
f"Content-Type: application/json\r\n"
|
else:
|
||||||
f"Content-Length: {len(body)}\r\n"
|
body = "Not Found"
|
||||||
f"\r\n{body}"
|
status = "404 Not Found"
|
||||||
)
|
content_type = "text/plain"
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
writer.write(resp.encode())
|
resp = (
|
||||||
await writer.drain()
|
f"HTTP/1.0 {status}\r\n"
|
||||||
writer.close()
|
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)
|
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:
|
async with server:
|
||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
# Register Dream system job (idempotent on restart)
|
# Register Dream system job (idempotent on restart)
|
||||||
|
|||||||
@@ -2763,11 +2763,23 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
|
|||||||
assert "port 18792" in result.stdout
|
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(
|
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:
|
) -> None:
|
||||||
config_file = _write_instance_config(tmp_path)
|
config_file = _write_instance_config(tmp_path)
|
||||||
config = Config()
|
config = Config()
|
||||||
|
config.gateway.host = host
|
||||||
config.gateway.port = 18791
|
config.gateway.port = 18791
|
||||||
captured: dict[str, object] = {}
|
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)])
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert captured["host"] == "127.0.0.1"
|
assert captured["host"] == host
|
||||||
assert captured["port"] == 18791
|
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]:
|
def _call_handler(path: str) -> tuple[str, _FakeWriter]:
|
||||||
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
|
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
|
||||||
writer = _FakeWriter()
|
writer = _FakeWriter()
|
||||||
handler = captured["handler"]
|
asyncio.run(health_handler(_FakeReader(request), writer))
|
||||||
assert callable(handler)
|
|
||||||
asyncio.run(handler(_FakeReader(request), writer))
|
|
||||||
return writer.output.decode(), writer
|
return writer.output.decode(), writer
|
||||||
|
|
||||||
root_response, root_writer = _call_handler("/")
|
root_response, root_writer = _call_handler("/")
|
||||||
assert root_writer.closed is True
|
assert root_writer.closed is True
|
||||||
assert "HTTP/1.0 404 Not Found" in root_response
|
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")
|
assert root_response.endswith("\r\n\r\nNot Found")
|
||||||
|
|
||||||
health_response, health_writer = _call_handler("/health")
|
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 "HTTP/1.0 404 Not Found" in missing_response
|
||||||
assert missing_response.endswith("\r\n\r\nNot Found")
|
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(
|
def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user