diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 29ce7bb3c..56d1a901d 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -815,6 +815,25 @@ 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 the browser keeps + +The vhost tells the browser what to cache. The bundle under `/static/` has +content hashes in its names, so the browser keeps it for a year. The entry +document, `/sw.js`, the manifest, the icons and the theme's `colors.css` have +fixed names, so the browser revalidates them on every load, and a deploy +reaches an open browser at its next load. A file that doesn't exist answers +404, including a `/static/` file from an older build, rather than the entry +document. + +The service worker fetches its shell past the HTTP cache and refreshes the +cached entry document on each page load that reaches the network. When its +install fails, the previous worker keeps running and the browser console shows +why. + +The pages carry a content security policy: scripts and connections only from +this origin, images from this origin, `data:` and https, and no framing from +another origin. + ## 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/sw.js b/frontend/packages/swarm-ui/src/sw.js index 911b7c021..c34b2d70a 100644 --- a/frontend/packages/swarm-ui/src/sw.js +++ b/frontend/packages/swarm-ui/src/sw.js @@ -45,9 +45,41 @@ const SHELL_ASSETS = [ "/manifest.webmanifest", ]; +// Every shell asset, fetched past the HTTP cache. `cache.addAll` goes through +// it, so it could store a stale copy of the entry document under the new +// version, and it rejects as a whole with nothing said when one asset fails. +// Here a failure is logged and the install fails with it, which leaves the +// previous worker in charge. A redirect counts as a failure: it is the login +// page answering for an ended session, and must not be cached as the shell. +async function precacheShell() { + const cache = await caches.open(CACHE_NAME); + await Promise.all( + SHELL_ASSETS.map(async (url) => { + const response = await fetch(url, { cache: "reload" }); + if (!response.ok || response.redirected) { + throw new Error( + `${url}: status ${response.status}${response.redirected ? " after a redirect" : ""}`, + ); + } + await cache.put(url, response); + }), + ); +} + +// Keep the cached entry document as fresh as the last navigation that +// reached the network, so the offline fallback is not stuck on install time. +function refreshShell(response) { + return caches + .open(CACHE_NAME) + .then((cache) => cache.put(SHELL_DOCUMENT, response)); +} + self.addEventListener("install", (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)), + precacheShell().catch((err) => { + console.error("swarm-ui: service worker install failed", err); + throw err; + }), ); // Take over from any previous SW as soon as this one's installed — an // operator installing the PWA for the first time (or after a SW @@ -86,7 +118,14 @@ self.addEventListener("fetch", (event) => { // since every route renders the same SPA shell. if (event.request.mode === "navigate") { event.respondWith( - fetch(event.request).catch(() => caches.match(SHELL_DOCUMENT)), + fetch(event.request) + .then((response) => { + if (response.ok && !response.redirected) { + event.waitUntil(refreshShell(response.clone())); + } + return response; + }) + .catch(() => caches.match(SHELL_DOCUMENT)), ); return; } @@ -107,9 +146,13 @@ self.addEventListener("fetch", (event) => { // asset on the next offline/failed fetch. if (response.ok) { const copy = response.clone(); - caches - .open(CACHE_NAME) - .then((cache) => cache.put(event.request, copy)); + // Held open with `waitUntil`: the worker can be stopped as soon as + // it answers, which would drop a write still in flight. + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.put(event.request, copy)), + ); } return response; }) diff --git a/frontend/packages/swarm-ui/test/sw.test.mjs b/frontend/packages/swarm-ui/test/sw.test.mjs new file mode 100644 index 000000000..6f31f40e7 --- /dev/null +++ b/frontend/packages/swarm-ui/test/sw.test.mjs @@ -0,0 +1,148 @@ +// The service worker's own logic, run in a sandbox with a fake cache and a fake +// fetch: what the install keeps, when it fails, and when a navigation refreshes +// the cached entry document. Run by `npm test`. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const SOURCE = readFileSync(new URL("../src/sw.js", import.meta.url), "utf8"); +const ORIGIN = "https://swarm.example"; + +function res({ ok = true, redirected = false, status = 200 } = {}) { + const r = { ok, redirected, status, body: `body-${Math.random()}` }; + r.clone = () => ({ ...r }); + return r; +} + +// A worker loaded against a fake network. `network(url, init)` returns a +// response or throws; `stores` is every cache the worker opened. +function load(network) { + const handlers = {}; + const stores = new Map(); + const calls = []; + const errors = []; + const open = async (name) => { + if (!stores.has(name)) stores.set(name, new Map()); + const store = stores.get(name); + return { + put: async (key, value) => { + store.set(typeof key === "string" ? key : key.url, value); + }, + keys: async () => [...store.keys()], + }; + }; + const sandbox = { + self: { + location: { origin: ORIGIN }, + addEventListener: (type, fn) => (handlers[type] = fn), + skipWaiting: () => {}, + clients: { claim: async () => {} }, + }, + caches: { + open, + keys: async () => [...stores.keys()], + delete: async (name) => stores.delete(name), + match: async (url) => { + for (const store of stores.values()) { + if (store.has(url)) return store.get(url); + } + return undefined; + }, + }, + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input.url; + calls.push({ url, init }); + return network(url, init); + }, + URL, + console: { error: (...args) => errors.push(args.join(" ")) }, + }; + runInNewContext(SOURCE, sandbox); + return { handlers, stores, calls, errors }; +} + +function install(worker) { + const waits = []; + worker.handlers.install({ waitUntil: (p) => waits.push(p) }); + return Promise.all(waits); +} + +function navigate(worker, url) { + const waits = []; + let answer; + worker.handlers.fetch({ + request: { url: ORIGIN + url, mode: "navigate" }, + respondWith: (p) => (answer = p), + waitUntil: (p) => waits.push(p), + }); + return { answer, settled: () => Promise.all(waits) }; +} + +test("the install fetches every shell asset past the HTTP cache and keeps it", async () => { + const worker = load(() => res()); + await install(worker); + assert.ok(worker.calls.length >= 7); + assert.ok(worker.calls.every((c) => c.init?.cache === "reload")); + const [store] = [...worker.stores.values()]; + assert.ok(store.has("/")); + assert.ok(store.has("/static/colors.css")); +}); + +test("a redirected asset fails the install and says why", async () => { + const worker = load((url) => + url === "/static/colors.css" ? res({ redirected: true }) : res(), + ); + await assert.rejects(install(worker), /colors\.css.*redirect/); + assert.equal(worker.errors.length, 1); +}); + +test("an asset the server refuses fails the install", async () => { + const worker = load((url) => + url === "/manifest.webmanifest" ? res({ ok: false, status: 404 }) : res(), + ); + await assert.rejects(install(worker), /manifest\.webmanifest.*404/); +}); + +test("a navigation that reaches the network refreshes the cached entry document", async () => { + const fresh = res(); + const net = load(() => fresh); + await install(net); + const nav = navigate(net, "/agents/iris/terminal"); + const answered = await nav.answer; + await nav.settled(); + assert.equal(answered, fresh); + const [store] = [...net.stores.values()]; + assert.equal(store.get("/").body, fresh.body); +}); + +test("a redirected or failed navigation does not replace the cached entry document", async () => { + let next = res(); + const worker = load((url) => (url === "/" ? res() : next)); + await install(worker); + const [store] = [...worker.stores.values()]; + const before = store.get("/"); + for (const bad of [ + res({ redirected: true }), + res({ ok: false, status: 502 }), + ]) { + next = bad; + const nav = navigate(worker, "/agents"); + await nav.answer; + await nav.settled(); + assert.equal(store.get("/"), before); + } +}); + +test("offline, a navigation is answered from the cached entry document", async () => { + let online = true; + const worker = load((url) => { + if (online || url === "/") return res(); + throw new TypeError("offline"); + }); + await install(worker); + online = false; + const [store] = [...worker.stores.values()]; + const nav = navigate(worker, "/jobs"); + assert.equal(await nav.answer, store.get("/")); +}); diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index b93610ee4..ee3cf1bf6 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -56,6 +56,38 @@ let ${authRequest} error_page 401 =302 ${loginRedirect}; ''; + + # What the swarm UI's pages may load. It is a same-origin SPA that holds an + # operator session: no inline script, nothing from another origin except + # images (agent messages can render a markdown image), and no framing + # outside this origin. `style-src` keeps `'unsafe-inline'` because the + # bundle sets inline styles. + contentSecurityPolicy = lib.concatStringsSep "; " [ + "default-src 'self'" + "img-src 'self' data: https:" + "style-src 'self' 'unsafe-inline'" + "frame-ancestors 'self'" + "base-uri 'self'" + "form-action 'self'" + "object-src 'none'" + ]; + # `add_header` in a location replaces every header inherited from the + # server, so each static location repeats the gateway's own and adds the + # policy and its cache rule. The bundle's files are named by content hash + # (`/static/main-.js`): those never change and are cached for a year. + # The cache rule has no `always`, so an error answer (a 404 for a hash that + # is gone) is never marked cacheable. + # Everything else the page loads by a fixed name (the entry document, + # `sw.js`, the manifest, `colors.css`, which the theme swaps) must be + # revalidated, or a deploy can leave a browser on an old document that points + # at scripts that no longer exist. + staticHeaders = cache: '' + ${gatewayCfg.lib.securityHeaders} + add_header Content-Security-Policy "${contentSecurityPolicy}" always; + add_header Cache-Control "${cache}"; + ''; + revalidate = "no-cache"; + immutable = "public, max-age=31536000, immutable"; swarmCfg = config.services.hyperhive.swarm; hiveDomain = config.services.hyperhive.domain; @@ -185,26 +217,57 @@ in root = "${deployCfg.swarm-controller.ui.package}"; extraConfig = '' ${swarmAuthRequest} + ${staticHeaders revalidate} # SPA: any path the bundle routes client-side is served the # entry document rather than a 404 from the filesystem. try_files $uri /index.html; ''; }; - } - // lib.optionalAttrs (stylixThemeColors != null) { - # Stylix theming (see the `let` block above): overrides just - # this one file from `deployCfg.swarm-controller.ui.package`'s own static root, rather - # than copying the whole tree to change one file inside it — - # nginx resolves the more specific `=` exact match over the - # `/` prefix root above, so this simply doesn't exist (falling - # through to the package's own untouched colors.css) when - # there's no active palette. Same `auth_request` gate as every - # other location here — no reason for this one path to have a - # different failure mode than the page that loads it. - "= /static/colors.css" = { - alias = "${themedColorsCss stylixThemeColors}"; - extraConfig = swarmAuthRequest; + # The hashed bundle. A file that is not there is a 404, not the entry + # document: after a deploy a browser still holding the old page asks for + # `/static/main-.js`, and HTML answered with 200 left a blank + # page with only a MIME-type error to explain it. + "^~ /static/" = { + root = "${deployCfg.swarm-controller.ui.package}"; + extraConfig = '' + ${swarmAuthRequest} + ${staticHeaders immutable} + try_files $uri =404; + ''; }; + # The files at the root, by their fixed names: the worker (a fixed name + # is how the browser finds an update), the manifest, the icons. They are + # revalidated, and one that is not there is a 404 rather than the entry + # document. One path segment only, so `/api/openapi.json` and the + # `/.well-known/` answers keep their own locations. + "~ ^/[^/]+\\.[A-Za-z0-9]+$" = { + root = "${deployCfg.swarm-controller.ui.package}"; + extraConfig = '' + ${swarmAuthRequest} + ${staticHeaders revalidate} + try_files $uri =404; + ''; + }; + # The one unhashed file under `/static/`: the theme swaps it (see the + # stylix note above), so it is revalidated, not immutable. + "= /static/colors.css" = + if stylixThemeColors != null then + { + alias = "${themedColorsCss stylixThemeColors}"; + extraConfig = '' + ${swarmAuthRequest} + ${staticHeaders revalidate} + ''; + } + else + { + root = "${deployCfg.swarm-controller.ui.package}"; + extraConfig = '' + ${swarmAuthRequest} + ${staticHeaders revalidate} + try_files $uri =404; + ''; + }; } // lib.optionalAttrs servesMatrixDiscovery { # Same JSON, headers and `:443` as the hive-domain copy in diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 73878a009..aabeac254 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -445,6 +445,43 @@ let && bare.services.hyperhive.swarm.serviceDomains != [ ] && lib.hasInfix "want_svc=0" bare.systemd.services.swarm-services-cert.script; } + { + # 🩸 A deploy left browsers on an old entry document that named + # `/static/main-.js`; with no cache rule the browser kept that + # document for a heuristic stretch of days, and the missing script was + # answered with the entry document (200, text/html), so the page was blank + # with only a MIME-type error to say why. The hashed bundle is immutable + # and a missing file is a 404; every fixed name (the entry document, the + # worker and the manifest, the theme's `colors.css`) is revalidated. The + # cache rule has no `always`, so a 404 is never marked cacheable. + name = "the swarm UI's static locations cache by name, 404 a missing file and carry the policy"; + ok = + let + l = allLocal.services.nginx.virtualHosts."t.local".locations; + has = key: needle: lib.hasInfix needle l.${key}.extraConfig; + gated = key: has key "auth_request /__hive_authelia;"; + armed = + key: + has key "add_header Content-Security-Policy \"default-src 'self'" + && has key "add_header X-Frame-Options"; + rootFiles = "~ ^/[^/]+\\.[A-Za-z0-9]+$"; + in + has "^~ /static/" "add_header Cache-Control \"public, max-age=31536000, immutable\";" + && has "^~ /static/" "try_files $uri =404;" + && has "/" "add_header Cache-Control \"no-cache\";" + && has "/" "try_files $uri /index.html;" + && has "= /static/colors.css" "add_header Cache-Control \"no-cache\";" + && !(has "= /static/colors.css" "immutable") + && has rootFiles "add_header Cache-Control \"no-cache\";" + && has rootFiles "try_files $uri =404;" + && lib.all (k: gated k && armed k) [ + "/" + "^~ /static/" + "= /static/colors.css" + rootFiles + ] + && !(has "^~ /static/" "Cache-Control \"public, max-age=31536000, immutable\" always"); + } { # nixos asserts when a vhost declares both, so this is also a # statement that the `removeAttrs` upstream of it still happens.