diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 66676fd7..29ce7bb3 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -805,6 +805,16 @@ Why a group and not "any session": agents are authelia subjects too, so _authenticated_ includes every agent in the swarm. The group is the only thing standing between "an operator's page" and "anyone with a session." +## When your login ends + +When the session ends while a page is open, the page's next `/api/` request (a +poll, a form, or a stream that dies) gets a plain 401 with a +`WWW-Authenticate` header from the gateway, and the page reloads. The reload +lands on the login and, after you log in, back on the same address. A page +request still gets the login redirect, and `swarmctl --swarm` still treats the +401 as a sign to log in again. The page skips a second reload within ten +seconds, so a page that keeps answering 401 can't loop. + ## What it costs to be reachable hyperhive publishes the apex to the hive's resolver like every other diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts index b7f0a931..f1d8d215 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts @@ -21,6 +21,7 @@ import { connectionAfterError, type ConnectionState, } from "../../ui/stream/connection.js"; +import { probeSession } from "../../ui/session/session.js"; export type { ConnectionState }; @@ -87,7 +88,12 @@ export function useSwarmAgentStateStream( // badge cluster over — the next valid header replaces it. } }; - es.onerror = () => setConnection(connectionAfterError(es.readyState)); + es.onerror = () => { + const state = connectionAfterError(es.readyState); + setConnection(state); + // The browser gave up: say whether the session ended, which `EventSource` does not tell. + if (state === "closed") probeSession(); + }; return () => es.close(); }, [streamUrl, attempt]); diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts index d0bf3765..c5ed0a9c 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts @@ -16,6 +16,7 @@ import { connectionAfterError, type ConnectionState, } from "../../ui/stream/connection.js"; +import { probeSession } from "../../ui/session/session.js"; export type { ConnectionState }; @@ -68,7 +69,12 @@ export function useSwarmTermStream( } setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false })); }; - es.onerror = () => setConnection(connectionAfterError(es.readyState)); + es.onerror = () => { + const state = connectionAfterError(es.readyState); + setConnection(state); + // The browser gave up: say whether the session ended, which `EventSource` does not tell. + if (state === "closed") probeSession(); + }; return () => es.close(); }, [streamUrl, attempt]); diff --git a/frontend/packages/swarm-ui/src/main.tsx b/frontend/packages/swarm-ui/src/main.tsx index 6294fc42..c4570bfa 100644 --- a/frontend/packages/swarm-ui/src/main.tsx +++ b/frontend/packages/swarm-ui/src/main.tsx @@ -1,5 +1,9 @@ import { render } from "preact"; import { App } from "./App.js"; +import { installSessionGuard } from "./ui/session/session.js"; + +// Before anything fetches: a 401 that means "log in again" reloads the page. +installSessionGuard(); const root = document.getElementById("root"); if (root) { diff --git a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts index 44e0c3d7..dcbfb49b 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts +++ b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts @@ -15,6 +15,7 @@ import { connectionAfterError, type ConnectionState, } from "../../ui/stream/connection.js"; +import { probeSession } from "../../ui/session/session.js"; export type { ConnectionState }; @@ -70,7 +71,12 @@ export function useMessageStream(sinceMinutes: number): { pending.push(m); if (frame === null) frame = requestAnimationFrame(flush); }; - es.onerror = () => setConnection(connectionAfterError(es.readyState)); + es.onerror = () => { + const state = connectionAfterError(es.readyState); + setConnection(state); + // The browser gave up: say whether the session ended, which `EventSource` does not tell. + if (state === "closed") probeSession(); + }; return () => { drop(); es.close(); diff --git a/frontend/packages/swarm-ui/src/ui/session/session.ts b/frontend/packages/swarm-ui/src/ui/session/session.ts new file mode 100644 index 00000000..27e1217e --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/session/session.ts @@ -0,0 +1,91 @@ +// What the swarm UI does when the operator's login ends. The gateway answers +// an `/api/` request that has no session with a plain 401 and a +// `WWW-Authenticate` header (a page request still gets the login redirect), and +// until now nothing read it: polled pages showed "Failed to fetch", forms +// failed the same way and streams went to "closed" until a manual reload. +// Now that 401 reloads the page, which lands on the login and, once logged in, +// back on the same address. +// +// `installSessionGuard` wraps `fetch` once at startup, so the ~40 call sites +// need no change; `probeSession` is for a stream, whose `EventSource` is told +// only that it failed, never why. + +const API_PREFIX = "/api/"; +const MIN_RELOAD_GAP_MS = 10_000; +const RELOAD_KEY = "swarm-ui:session-reload-at"; +// Not `/api/whoami`: the gateway proxies that to authelia, which answers a +// logged-out caller its own way. Any route behind the gateway's session check +// would do; this one is a cheap read. +const PROBE_URL = "/api/hives/status"; + +/** Whether a response means the session ended: a 401 from the gateway (it + * carries the challenge header; the controller's own 401s do not) on a + * same-origin `/api/` URL. */ +export function isSessionExpired( + url: string, + status: number, + hasChallenge: boolean, + origin: string, +): boolean { + if (status !== 401 || !hasChallenge) return false; + const u = new URL(url, origin); + return u.origin === origin && u.pathname.startsWith(API_PREFIX); +} + +/** Whether a reload may happen now. A reload that lands on a page which still + * answers 401 would otherwise loop, so a second one within the gap is skipped; + * a clock that went backwards counts as no reload yet. */ +export function mayReload(lastAtMs: number | null, nowMs: number): boolean { + return ( + lastAtMs === null || + nowMs < lastAtMs || + nowMs - lastAtMs >= MIN_RELOAD_GAP_MS + ); +} + +function reloadForLogin(): void { + const now = Date.now(); + let last: number | null = null; + try { + const saved = Number(sessionStorage.getItem(RELOAD_KEY)); + last = Number.isFinite(saved) && saved > 0 ? saved : null; + if (!mayReload(last, now)) return; + sessionStorage.setItem(RELOAD_KEY, String(now)); + } catch { + // No session storage (a locked-down browser): reload anyway; the gateway + // sends a logged-out page to the login, so this cannot loop there. + } + window.location.reload(); +} + +function urlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** Wrap `window.fetch` so a 401 that means "log in again" reloads the page. */ +export function installSessionGuard(): void { + const original = window.fetch.bind(window); + window.fetch = async (input, init) => { + const res = await original(input, init); + if ( + isSessionExpired( + urlOf(input), + res.status, + res.headers.has("www-authenticate"), + window.location.origin, + ) + ) { + reloadForLogin(); + } + return res; + }; +} + +/** Ask the gateway whether the session is still good, after a stream failed. + * An expired session answers 401 and the guard reloads; any other answer, or + * none, leaves the stream's own "closed" state to say what happened. */ +export function probeSession(): void { + fetch(PROBE_URL, { cache: "no-store" }).catch(() => {}); +} diff --git a/frontend/packages/swarm-ui/test/session.test.mjs b/frontend/packages/swarm-ui/test/session.test.mjs new file mode 100644 index 00000000..7a85f57e --- /dev/null +++ b/frontend/packages/swarm-ui/test/session.test.mjs @@ -0,0 +1,38 @@ +// When the swarm UI reads a response as "the login ended". Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isSessionExpired, mayReload } from "../src/ui/session/session.ts"; + +const ORIGIN = "https://swarm.example"; + +test("a gateway 401 on an api address is an ended session", () => { + assert.equal(isSessionExpired("/api/agents/status", 401, true, ORIGIN), true); + assert.equal( + isSessionExpired(`${ORIGIN}/api/jobq/graph`, 401, true, ORIGIN), + true, + ); +}); + +test("a 401 without the gateway's challenge is the controller's own", () => { + assert.equal(isSessionExpired("/api/agents", 401, false, ORIGIN), false); +}); + +test("other statuses and other addresses are not an ended session", () => { + assert.equal(isSessionExpired("/api/agents", 403, true, ORIGIN), false); + assert.equal(isSessionExpired("/api/agents", 500, true, ORIGIN), false); + assert.equal(isSessionExpired("/static/main.js", 401, true, ORIGIN), false); + assert.equal( + isSessionExpired("https://other.example/api/x", 401, true, ORIGIN), + false, + ); +}); + +test("a reload is skipped when one just happened", () => { + assert.equal(mayReload(null, 1_000_000), true); + assert.equal(mayReload(1_000_000, 1_005_000), false); + assert.equal(mayReload(1_000_000, 1_010_000), true); +}); + +test("a clock that went backwards does not block the reload", () => { + assert.equal(mayReload(2_000_000, 1_000_000), true); +}); diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index b693c60b..b93610ee 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -252,11 +252,24 @@ in proxy_buffering off; ''; }; + # A refused request gets the login page only when it asks for a page. + # The swarm UI calls `/api/` with `fetch` and `EventSource`, and a + # cross-origin redirect makes both fail with an error that says nothing + # about why, so after a session ends every poll, form and stream broke + # until a reload. Anything that does not ask for `text/html` gets a plain + # 401 with a `WWW-Authenticate` header, which the UI reads as "log in + # again" and answers with a reload. The header is also how the UI tells + # this 401 from one the controller sends for its own reasons. "@swarm_api_unauthorized".extraConfig = '' if ($http_authorization) { + add_header WWW-Authenticate 'Bearer realm="swarm"' always; return 401; } - return 302 ${loginRedirect}; + if ($http_accept ~* "text/html") { + return 302 ${loginRedirect}; + } + add_header WWW-Authenticate 'Bearer realm="swarm"' always; + return 401; ''; # ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`, # and that is deliberate rather than an omission. diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 34cac809..73878a00 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -457,9 +457,12 @@ let } { # `swarmctl --swarm` reads a 401 as "log in again"; a 302 would hand it - # the login page instead. The `/` clause is the control: the browser - # route still redirects, so a pass means the two routes differ. - name = "the controller API answers a refused bearer caller 401 and a browser the login"; + # the login page instead. The swarm UI's own `fetch` and `EventSource` + # need the same 401, because a redirect to another origin makes both fail + # with an error that says nothing about the session; only a request that + # asks for `text/html` gets the login. The `/` clause is the control: the + # page route still redirects, so a pass means the two routes differ. + name = "the controller API answers a refused bearer caller or fetch 401 and a page request the login"; ok = let l = allLocal.services.nginx.virtualHosts."t.local".locations; @@ -469,8 +472,9 @@ let lib.hasInfix "error_page 401 = @swarm_api_unauthorized;" api && !(lib.hasInfix "=302" api) && lib.hasInfix ''proxy_set_header Authorization "";'' api - && lib.hasInfix "if ($http_authorization) {\n return 401;\n}" refused - && lib.hasInfix "return 302 https://auth.t.local/?rd=$target_url;" refused + && lib.hasInfix "if ($http_authorization) {\n add_header WWW-Authenticate 'Bearer realm=\"swarm\"' always;\n return 401;\n}" refused + && lib.hasInfix "if ($http_accept ~* \"text/html\") {\n return 302 https://auth.t.local/?rd=$target_url;\n}" refused + && lib.hasInfix "}\nadd_header WWW-Authenticate 'Bearer realm=\"swarm\"' always;\nreturn 401;" refused && lib.hasInfix "error_page 401 =302 https://auth.t.local/" l."/".extraConfig; } {