diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 29ce7bb3..56d1a901 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 911b7c02..c34b2d70 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/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index b93610ee..ee3cf1bf 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 73878a00..aabeac25 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.