diff --git a/deploy/docker/server.py b/deploy/docker/server.py index eb0fddadf..70352913a 100644 --- a/deploy/docker/server.py +++ b/deploy/docker/server.py @@ -292,6 +292,14 @@ async def _timeline_updater(): async def root(): return RedirectResponse("/playground") + +# Pre-0.9 docs pointed at /monitor for the dashboard UI, which now lives at +# /dashboard; /monitor is the monitoring API prefix and has no page of its own. +# Only this exact path redirects - /monitor/* stays gated (see public_paths). +@app.get("/monitor", include_in_schema=False) +async def monitor_ui_redirect(): + return RedirectResponse("/dashboard") + # ─────────────────── infra / middleware ───────────────────── redis = aioredis.from_url(_build_redis_url(config)) @@ -392,7 +400,10 @@ def _current_api_token() -> str: app.add_middleware( AuthGateMiddleware, token_provider=_current_api_token, - public_paths={HEALTH_PATH, "/token", "/"}, + # Exact paths only: "/monitor" reaches the redirect above, while every + # "/monitor/*" API route (incl. /monitor/ws and the admin actions) keeps + # requiring a credential. + public_paths={HEALTH_PATH, "/token", "/", "/monitor"}, public_prefixes=_UI_PREFIXES, ) diff --git a/deploy/docker/tests/test_legacy_compat.py b/deploy/docker/tests/test_legacy_compat.py index 2a76d6e27..a8a4039e3 100644 --- a/deploy/docker/tests/test_legacy_compat.py +++ b/deploy/docker/tests/test_legacy_compat.py @@ -1,8 +1,9 @@ """ Behavioral tests for 0.9.x legacy-compatibility handling: - * root redirect - "/" is public and redirects to /playground instead of - dying in the auth gate with a bare 401; /monitor and the + * UI redirects - "/" and "/monitor" are public and redirect to + /playground and /dashboard instead of dying in the auth + gate with a bare 401; the /monitor/* API routes and the data routes stay gated. * output_path - /screenshot and /pdf still accept the 0.8.x output_path field but return a warning saying no file was written, @@ -30,10 +31,10 @@ def _bearer() -> dict: return {"Authorization": f"Bearer {create_access_token({'sub': 'user@x.com'}, scope='data')}"} -# ───────────────────────── root redirect ───────────────────────── +# ───────────────────────── UI redirects ───────────────────────── -class TestRootRedirect: +class TestUiRedirects: def test_root_is_public_and_redirects_to_playground(self, stock_client): r = stock_client.get("/", follow_redirects=False) assert r.status_code in (302, 307), ( @@ -42,13 +43,43 @@ def test_root_is_public_and_redirects_to_playground(self, stock_client): ) assert r.headers["location"] == "/playground" - def test_monitor_and_data_routes_stay_gated(self, stock_client): - # /monitor must not serve content without a token; a future - # /monitor -> /dashboard redirect is fine (the target is UI-public), - # so accept 401 or a redirect, never 200. + def test_monitor_redirects_to_dashboard(self, stock_client): + # Pre-0.9 docs sent people to /monitor for the dashboard UI. That exact + # path redirects to /dashboard (UI-public) instead of dead-ending in the + # auth gate with a bare 401. r = stock_client.get("/monitor", follow_redirects=False) - assert r.status_code in (401, 302, 307, 308) - assert stock_client.get("/monitor/health").status_code == 401 + assert r.status_code in (302, 307), ( + f"GET /monitor returned {r.status_code}; expected a redirect. The " + f"auth gate must allow the exact path '/monitor' so the route runs." + ) + assert r.headers["location"] == "/dashboard" + + @pytest.mark.parametrize( + "method,path", + [ + ("get", "/monitor/health"), + ("get", "/monitor/requests"), + ("get", "/monitor/browsers"), + ("get", "/monitor/timeline"), + ("get", "/monitor/logs/errors"), + ("post", "/monitor/actions/cleanup"), + ("post", "/monitor/actions/kill_browser"), + ("post", "/monitor/stats/reset"), + ], + ) + def test_monitor_api_routes_stay_gated(self, stock_client, method, path): + # The redirect above is exact-path only. Making the /monitor *prefix* + # public would expose request logs, browser state and the destructive + # admin actions without a credential. + assert getattr(stock_client, method)(path).status_code == 401 + + def test_monitor_websocket_stays_gated(self, stock_client): + # /monitor/ws is where live stats stream; it must not open unauthenticated. + with pytest.raises(Exception): + with stock_client.websocket_connect("/monitor/ws"): + pass + + def test_data_routes_stay_gated(self, stock_client): assert stock_client.post("/crawl", json={"urls": ["https://x"]}).status_code == 401 diff --git a/docs/md_v2/core/self-hosting.md b/docs/md_v2/core/self-hosting.md index 09e5e37a5..68a8a1065 100644 --- a/docs/md_v2/core/self-hosting.md +++ b/docs/md_v2/core/self-hosting.md @@ -1495,9 +1495,10 @@ Access the **built-in real-time monitoring dashboard** for complete operational http://localhost:11235/dashboard ``` -> ⚠️ The dashboard UI lives at `/dashboard` — **not** `/monitor`, which is the -> API namespace (`/monitor/health`, `/monitor/ws`, …) and returns -> `{"detail": "Authentication required"}` in a browser. On the dashboard, paste +> ⚠️ The dashboard UI lives at `/dashboard`. `/monitor` is the API namespace +> (`/monitor/health`, `/monitor/ws`, …); older docs pointed there, so that exact +> URL now redirects to `/dashboard` for convenience — the `/monitor/*` routes +> themselves still require a token. On the dashboard, paste > your API token into the **API token** bar (top right) and click **Set**; the > WebSocket then connects and live stats appear.