From f981ca1bf150846a46989b96d4da1a1e7ef6ec00 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Wed, 22 Jul 2026 06:52:45 +0300 Subject: [PATCH] Return clear node-missing JSON and reset Web UI auth on 401. Keep Ingress from rewriting API 404/405 into branded HTML; document curl/auth and a prompt for sibling sims. --- app/handlers/common.py | 18 +++- app/web/index.html | 23 ++++- docs/kubernetes.md | 3 + .../fix-api-errors-auth-ui-other-sims.md | 92 +++++++++++++++++++ docs/ru/kubernetes.md | 3 + docs/ru/troubleshooting.md | 39 ++++++++ docs/troubleshooting.md | 39 ++++++++ .../values-ingress-example.yaml | 7 ++ tests/unit/test_extended_handlers.py | 2 +- 9 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 docs/prompts/fix-api-errors-auth-ui-other-sims.md diff --git a/app/handlers/common.py b/app/handlers/common.py index bd489c7..5a98188 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -76,7 +76,11 @@ async def require_node(request: Request, node: str) -> None: node, ) if not exists: - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) async def cluster_metadata(request: Request) -> dict[str, Any]: @@ -105,7 +109,11 @@ async def node_metadata(request: Request, node: str) -> dict[str, Any]: node, ) if row is None: - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) return state(row["metadata"]) @@ -116,7 +124,11 @@ async def save_node_metadata(request: Request, node: str, metadata: dict[str, An json.dumps(metadata, sort_keys=True), ) if status != "UPDATE 1": - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) def storage_payload(row: Any) -> dict[str, Any]: diff --git a/app/web/index.html b/app/web/index.html index 5cfd2fd..b273fda 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4822,6 +4822,7 @@ state.ticket = null; state.csrf = null; state.username = null; + document.cookie = "PVEAuthCookie=; Max-Age=0; path=/; SameSite=Strict"; localStorage.removeItem(LS_AUTH); setAuth(false); } @@ -6019,16 +6020,29 @@ } } - function logout() { + function clearSession(options = {}) { + const { toastMessage = null, toastKind = "info", closePanel = true } = options; state.ticket = null; state.csrf = null; state.username = null; document.cookie = "PVEAuthCookie=; Max-Age=0; path=/; SameSite=Strict"; setAuth(false); persistAuth(); - closeAuthPanel(); + if (closePanel) closeAuthPanel(); resetClusterStats("—"); - toast("Signed out", "info"); + if (toastMessage) toast(toastMessage, toastKind); + } + + function logout() { + clearSession({ toastMessage: "Signed out", toastKind: "info" }); + } + + function expireSession() { + clearSession({ + toastMessage: "Session expired — sign in again", + toastKind: "warn", + closePanel: false, + }); } function updateStatusBadge(status) { @@ -6101,6 +6115,9 @@ const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { parsed = text; } + if (res.status === 401 && (state.ticket || state.username)) { + expireSession(); + } return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) }; } diff --git a/docs/kubernetes.md b/docs/kubernetes.md index dbe8b55..4c0e338 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -60,6 +60,9 @@ What this does: 6. Creates an Ingress with `cert-manager.io/cluster-issuer: letsencrypt-prod` and a TLS secret `proxmox-api-simulator-tls`. +7. Sets Ingress annotations so nginx does not replace API JSON 404/405 with + branded HTML error pages (`proxy-intercept-errors: false`, narrow + `custom-http-errors`). See [Troubleshooting](troubleshooting.md#ingress-returns-branded-html-404--nginx-405-instead-of-json). DNS for `pve-sim.example.com` must point at your Ingress controller. Then: diff --git a/docs/prompts/fix-api-errors-auth-ui-other-sims.md b/docs/prompts/fix-api-errors-auth-ui-other-sims.md new file mode 100644 index 0000000..520b03e --- /dev/null +++ b/docs/prompts/fix-api-errors-auth-ui-other-sims.md @@ -0,0 +1,92 @@ +# Prompt: fix API error bodies + Web UI session on 401 (other simulators) + +Copy everything below the line into a chat opened on **ovirt_api_simulator**, +**vmware_api_simulator**, or **openstack_api_simulator**. Adapt product names +(API path prefixes, cookie/CSRF names, Helm chart paths) to that repo. + +--- + +## Task + +Fix the same class of production issues already fixed in +`proxmox_api_simulator` (reference implementation). Do **not** claim the +simulator is “incomplete” or “not supported”; keep durable, realistic API +behavior. + +### Symptoms seen behind Ingress + +1. Client calls a path with a **wrong / missing resource id** (e.g. bad node / + host / datacenter name). Instead of the simulator’s JSON/XML error, the + browser or curl receives a **branded HTML 404** (“page not found”) from the + cluster Ingress / custom error pages. +2. Client sends a mutation (**POST/PUT/DELETE**) that the Ingress rejects or + rewrites → **nginx HTML 405** instead of the simulator’s auth/validation + error. +3. In the **Web UI**, after the API returns **401** (expired session), the + response panel says unauthorized but the **header still shows the signed-in + user** (does not switch to Guest / signed-out). + +Root cause for (1)/(2) is usually **ingress-nginx** +`custom-http-errors` / `proxy-intercept-errors` replacing upstream bodies. +Root cause for (3) is UI code that renders 401 text but does not clear +ticket/token/cookie/localStorage and call the same path as logout. + +### What to implement in THIS repo + +#### 1. Clearer “resource not found” API errors + +- Find the shared helper that validates that a node/host/cluster/datacenter + exists (equivalent of Proxmox `require_node`). +- On miss, return the **native API error shape** for this product with a + message that includes the bad id, e.g. Proxmox style: + `No such node ('pve01')` plus a field-level `errors` map when the product + uses one. +- Update unit tests that matched the old message string. +- Ensure the app always returns `Content-Type` appropriate for the API + (JSON/XML) and never an HTML error page from the app itself. + +#### 2. Web UI: treat HTTP 401 as session expiry + +- When any in-app API helper gets **401** and a session is currently stored: + - clear ticket/token/csrf/username (whatever this UI uses); + - clear auth cookies; + - clear persisted auth in localStorage/sessionStorage; + - update the header auth pill / badge to the signed-out / Guest state; + - show a short toast like “Session expired — sign in again”; + - still show the 401 explanation in the response panel. +- Refactor logout to share one `clearSession(...)` helper so logout and + expiry stay in sync. Do not leave stale “signed in as …” UI after 401. + +#### 3. Helm / Ingress example + docs + +- In the chart’s ingress example values (and kubernetes troubleshooting docs): + - `nginx.ingress.kubernetes.io/proxy-intercept-errors: "false"` + - `nginx.ingress.kubernetes.io/custom-http-errors: "502,503"` + (do **not** list 404/405 so API bodies are preserved) +- Document: if clients still see branded HTML 404/405, the cluster controller + is rewriting responses — fix Ingress annotations, not the simulator handlers. +- Document a correct authenticated mutation curl for this product (cookie / + token + CSRF if required + the body encoding this API expects — often + form-urlencoded, not bare JSON). + +### Out of scope + +- Do not add “not implemented in the simulator” user-facing messages. +- Do not change unrelated product semantics. +- Do not force-push or commit unless the user asks. + +### Done when + +- Missing resource → product-shaped API error with the id in the message + (verified by unit test and/or curl against the app, not only via Ingress). +- UI 401 → Guest / signed-out header + cleared storage/cookies. +- Ingress example + troubleshooting docs warn about HTML error-page rewrite + and show the correct curl pattern. +- Lint/tests for touched areas pass. + +### Reference (proxmox_api_simulator) + +- `app/handlers/common.py` — `require_node` / `node_metadata` message +- `app/web/index.html` — `clearSession` / `expireSession` / `showResponse` 401 +- `helm/proxmox-api-simulator/values-ingress-example.yaml` — annotations +- `docs/troubleshooting.md` — Ingress HTML 404/405 + curl notes diff --git a/docs/ru/kubernetes.md b/docs/ru/kubernetes.md index 0a3e28f..04da544 100644 --- a/docs/ru/kubernetes.md +++ b/docs/ru/kubernetes.md @@ -62,6 +62,9 @@ helm upgrade --install pve-sim ./helm/proxmox-api-simulator \ 6. Создаёт Ingress с `cert-manager.io/cluster-issuer: letsencrypt-prod` и TLS secret `proxmox-api-simulator-tls`. +7. Ставит annotations Ingress, чтобы nginx не подменял JSON 404/405 + брендированными HTML-страницами (`proxy-intercept-errors: false`, узкий + `custom-http-errors`). См. [Устранение неполадок](troubleshooting.md#ingress-отдаёт-брендированный-html-404--nginx-405-вместо-json). DNS для `pve-sim.example.com` должен указывать на ваш Ingress controller. Затем: diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 069aadd..33b4063 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -25,6 +25,45 @@ Workers могут повторять попытки, пока миграции - Мутация без `CSRFPreventionToken` в сессии по тикету. - API-токен с неверным форматом (`PVEAPIToken=user@realm!id=secret`). - Отказ ACL (сравните `auditor@pve` и `root@pam`). +- В Web UI при HTTP 401 локальная сессия сбрасывается, в шапке снова **Guest**; + войдите заново через Environment. + +## Ingress отдаёт брендированный HTML 404 / nginx 405 вместо JSON + +Симулятор отвечает на ошибки API JSON (`data` / `message` / `errors`). Если +видите HTML «страница не найдена» или страницу nginx **405**, тело ответа +подменил **Ingress / reverse proxy** (часто `custom-http-errors` у +ingress-nginx). + +На Ingress этого хоста (см. +`helm/proxmox-api-simulator/values-ingress-example.yaml`): + +```yaml +annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" +``` + +Проверьте с `Accept: application/json`. Несуществующий узел должен выглядеть так: + +```json +{"data": null, "message": "No such node ('pve01')", "errors": {"node": "No such node ('pve01')"}} +``` + +Имена узлов в seed: профиль `small` → `pve01`; `medium` / `ha-demo` → `pve1`… + +### Корректный аутентифицированный POST (как у Proxmox) + +Тело form-urlencoded, cookie тикета и CSRF-заголовок (не «голый» JSON POST): + +```bash +# после POST /api2/json/access/ticket → ticket + CSRFPreventionToken +curl -sk -X POST "https://HOST/api2/json/nodes/pve01/ceph/osd" \ + -H "CSRFPreventionToken: $CSRF" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -b "PVEAuthCookie=$TICKET" \ + --data-urlencode "dev=/dev/sdb" +``` ## Задача никогда не завершается diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 76b4131..26f28b5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -25,6 +25,45 @@ Declared methods on majors **6–9** should have handlers. If you see 501: - Mutation missing `CSRFPreventionToken` on a ticket session. - API token malformed (`PVEAPIToken=user@realm!id=secret`). - ACL denial (try `auditor@pve` vs `root@pam` to compare). +- In the Web UI, HTTP 401 clears the local session and shows **Guest** in the + header; sign in again from Environment. + +## Ingress returns branded HTML 404 / nginx 405 instead of JSON + +The simulator answers API errors as JSON (`data` / `message` / `errors`). If +you see a site HTML “page not found” or plain nginx **405** page, the +**Ingress / reverse proxy** replaced the upstream body (often via +`custom-http-errors` on the ingress-nginx controller). + +Fix on the Ingress for this host (see +`helm/proxmox-api-simulator/values-ingress-example.yaml`): + +```yaml +annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" +``` + +Then re-check with `Accept: application/json`. A missing node should look like: + +```json +{"data": null, "message": "No such node ('pve01')", "errors": {"node": "No such node ('pve01')"}} +``` + +Seeded node names: profile `small` → `pve01`; `medium` / `ha-demo` → `pve1`… + +### Correct authenticated POST (Proxmox-style) + +Use form-urlencoded body, ticket cookie, and CSRF header (not bare JSON POST): + +```bash +# after POST /api2/json/access/ticket → ticket + CSRFPreventionToken +curl -sk -X POST "https://HOST/api2/json/nodes/pve01/ceph/osd" \ + -H "CSRFPreventionToken: $CSRF" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -b "PVEAuthCookie=$TICKET" \ + --data-urlencode "dev=/dev/sdb" +``` ## Task never finishes diff --git a/helm/proxmox-api-simulator/values-ingress-example.yaml b/helm/proxmox-api-simulator/values-ingress-example.yaml index 95f49e9..f97f78e 100644 --- a/helm/proxmox-api-simulator/values-ingress-example.yaml +++ b/helm/proxmox-api-simulator/values-ingress-example.yaml @@ -37,6 +37,13 @@ seed: ingress: enabled: true className: nginx + # Keep API JSON bodies (404/405/401). Cluster-wide custom-http-errors HTML + # pages must not rewrite simulator error responses. + annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + # If the controller still injects branded HTML for 404/405, narrow or clear + # custom-http-errors on this Ingress (overrides controller defaults): + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" hosts: - host: pve-sim.example.com paths: diff --git a/tests/unit/test_extended_handlers.py b/tests/unit/test_extended_handlers.py index f74681a..9ba70cd 100644 --- a/tests/unit/test_extended_handlers.py +++ b/tests/unit/test_extended_handlers.py @@ -196,5 +196,5 @@ async def test_missing_node_returns_404() -> None: pool.node_exists = False handler = registry.get("/nodes/{node}/storage", "GET") assert handler is not None - with pytest.raises(ApiError, match="node does not exist"): + with pytest.raises(ApiError, match="No such node"): await handler(_request(pool), {"values": {"node": "missing"}})