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.
This commit is contained in:
+15
-3
@@ -76,7 +76,11 @@ async def require_node(request: Request, node: str) -> None:
|
|||||||
node,
|
node,
|
||||||
)
|
)
|
||||||
if not exists:
|
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]:
|
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,
|
node,
|
||||||
)
|
)
|
||||||
if row is None:
|
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"])
|
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),
|
json.dumps(metadata, sort_keys=True),
|
||||||
)
|
)
|
||||||
if status != "UPDATE 1":
|
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]:
|
def storage_payload(row: Any) -> dict[str, Any]:
|
||||||
|
|||||||
+20
-3
@@ -4822,6 +4822,7 @@
|
|||||||
state.ticket = null;
|
state.ticket = null;
|
||||||
state.csrf = null;
|
state.csrf = null;
|
||||||
state.username = null;
|
state.username = null;
|
||||||
|
document.cookie = "PVEAuthCookie=; Max-Age=0; path=/; SameSite=Strict";
|
||||||
localStorage.removeItem(LS_AUTH);
|
localStorage.removeItem(LS_AUTH);
|
||||||
setAuth(false);
|
setAuth(false);
|
||||||
}
|
}
|
||||||
@@ -6019,16 +6020,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function clearSession(options = {}) {
|
||||||
|
const { toastMessage = null, toastKind = "info", closePanel = true } = options;
|
||||||
state.ticket = null;
|
state.ticket = null;
|
||||||
state.csrf = null;
|
state.csrf = null;
|
||||||
state.username = null;
|
state.username = null;
|
||||||
document.cookie = "PVEAuthCookie=; Max-Age=0; path=/; SameSite=Strict";
|
document.cookie = "PVEAuthCookie=; Max-Age=0; path=/; SameSite=Strict";
|
||||||
setAuth(false);
|
setAuth(false);
|
||||||
persistAuth();
|
persistAuth();
|
||||||
closeAuthPanel();
|
if (closePanel) closeAuthPanel();
|
||||||
resetClusterStats("—");
|
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) {
|
function updateStatusBadge(status) {
|
||||||
@@ -6101,6 +6115,9 @@
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
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) };
|
return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ What this does:
|
|||||||
6. Creates an Ingress with
|
6. Creates an Ingress with
|
||||||
`cert-manager.io/cluster-issuer: letsencrypt-prod` and a TLS secret
|
`cert-manager.io/cluster-issuer: letsencrypt-prod` and a TLS secret
|
||||||
`proxmox-api-simulator-tls`.
|
`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:
|
DNS for `pve-sim.example.com` must point at your Ingress controller. Then:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -62,6 +62,9 @@ helm upgrade --install pve-sim ./helm/proxmox-api-simulator \
|
|||||||
6. Создаёт Ingress с
|
6. Создаёт Ingress с
|
||||||
`cert-manager.io/cluster-issuer: letsencrypt-prod` и TLS secret
|
`cert-manager.io/cluster-issuer: letsencrypt-prod` и TLS secret
|
||||||
`proxmox-api-simulator-tls`.
|
`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. Затем:
|
DNS для `pve-sim.example.com` должен указывать на ваш Ingress controller. Затем:
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,45 @@ Workers могут повторять попытки, пока миграции
|
|||||||
- Мутация без `CSRFPreventionToken` в сессии по тикету.
|
- Мутация без `CSRFPreventionToken` в сессии по тикету.
|
||||||
- API-токен с неверным форматом (`PVEAPIToken=user@realm!id=secret`).
|
- API-токен с неверным форматом (`PVEAPIToken=user@realm!id=secret`).
|
||||||
- Отказ ACL (сравните `auditor@pve` и `root@pam`).
|
- Отказ 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"
|
||||||
|
```
|
||||||
|
|
||||||
## Задача никогда не завершается
|
## Задача никогда не завершается
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,45 @@ Declared methods on majors **6–9** should have handlers. If you see 501:
|
|||||||
- Mutation missing `CSRFPreventionToken` on a ticket session.
|
- Mutation missing `CSRFPreventionToken` on a ticket session.
|
||||||
- API token malformed (`PVEAPIToken=user@realm!id=secret`).
|
- API token malformed (`PVEAPIToken=user@realm!id=secret`).
|
||||||
- ACL denial (try `auditor@pve` vs `root@pam` to compare).
|
- 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
|
## Task never finishes
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ seed:
|
|||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
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:
|
hosts:
|
||||||
- host: pve-sim.example.com
|
- host: pve-sim.example.com
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -196,5 +196,5 @@ async def test_missing_node_returns_404() -> None:
|
|||||||
pool.node_exists = False
|
pool.node_exists = False
|
||||||
handler = registry.get("/nodes/{node}/storage", "GET")
|
handler = registry.get("/nodes/{node}/storage", "GET")
|
||||||
assert handler is not None
|
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"}})
|
await handler(_request(pool), {"values": {"node": "missing"}})
|
||||||
|
|||||||
Reference in New Issue
Block a user