Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui vhost: cache by name, 404 a missing file, carry a content security policy

After a deploy a browser could keep the old entry document, which named a
bundle that no longer exists, and the missing script was answered with the
entry document, 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; the entry document,
the worker, the manifest, the icons and the theme's colors.css are revalidated.
The pages carry a content security policy. The service worker fetches its shell
past the HTTP cache, logs a failed install, refreshes the cached entry document
on each navigation and holds cache writes open with waitUntil. The proxy test
pins the cache rules, the 404s and the policy; docs/swarm/ui.md says what the
browser keeps.
This commit is contained in:
iris 2026-10-28 18:22:27 +01:00 • committed by mara
commit 24f6690fe5
4 changed files with 181 additions and 19 deletions

View file

@ -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 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. 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 ## What it costs to be reachable
hyperhive publishes the apex to the hive's resolver like every other hyperhive publishes the apex to the hive's resolver like every other

View file

@ -45,9 +45,41 @@ const SHELL_ASSETS = [
"/manifest.webmanifest", "/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) => { self.addEventListener("install", (event) => {
event.waitUntil( 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 // 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 // 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. // since every route renders the same SPA shell.
if (event.request.mode === "navigate") { if (event.request.mode === "navigate") {
event.respondWith( 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; return;
} }
@ -107,9 +146,13 @@ self.addEventListener("fetch", (event) => {
// asset on the next offline/failed fetch. // asset on the next offline/failed fetch.
if (response.ok) { if (response.ok) {
const copy = response.clone(); const copy = response.clone();
// 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 caches
.open(CACHE_NAME) .open(CACHE_NAME)
.then((cache) => cache.put(event.request, copy)); .then((cache) => cache.put(event.request, copy)),
);
} }
return response; return response;
}) })

View file

@ -56,6 +56,38 @@ let
${authRequest} ${authRequest}
error_page 401 =302 ${loginRedirect}; 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-<hash>.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; swarmCfg = config.services.hyperhive.swarm;
hiveDomain = config.services.hyperhive.domain; hiveDomain = config.services.hyperhive.domain;
@ -185,25 +217,56 @@ in
root = "${deployCfg.swarm-controller.ui.package}"; root = "${deployCfg.swarm-controller.ui.package}";
extraConfig = '' extraConfig = ''
${swarmAuthRequest} ${swarmAuthRequest}
${staticHeaders revalidate}
# SPA: any path the bundle routes client-side is served the # SPA: any path the bundle routes client-side is served the
# entry document rather than a 404 from the filesystem. # entry document rather than a 404 from the filesystem.
try_files $uri /index.html; try_files $uri /index.html;
''; '';
}; };
} # The hashed bundle. A file that is not there is a 404, not the entry
// lib.optionalAttrs (stylixThemeColors != null) { # document: after a deploy a browser still holding the old page asks for
# Stylix theming (see the `let` block above): overrides just # `/static/main-<oldhash>.js`, and HTML answered with 200 left a blank
# this one file from `deployCfg.swarm-controller.ui.package`'s own static root, rather # page with only a MIME-type error to explain it.
# than copying the whole tree to change one file inside it — "^~ /static/" = {
# nginx resolves the more specific `=` exact match over the root = "${deployCfg.swarm-controller.ui.package}";
# `/` prefix root above, so this simply doesn't exist (falling extraConfig = ''
# through to the package's own untouched colors.css) when ${swarmAuthRequest}
# there's no active palette. Same `auth_request` gate as every ${staticHeaders immutable}
# other location here — no reason for this one path to have a try_files $uri =404;
# different failure mode than the page that loads it. '';
"= /static/colors.css" = { };
# 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}"; alias = "${themedColorsCss stylixThemeColors}";
extraConfig = swarmAuthRequest; extraConfig = ''
${swarmAuthRequest}
${staticHeaders revalidate}
'';
}
else
{
root = "${deployCfg.swarm-controller.ui.package}";
extraConfig = ''
${swarmAuthRequest}
${staticHeaders revalidate}
try_files $uri =404;
'';
}; };
} }
// lib.optionalAttrs servesMatrixDiscovery { // lib.optionalAttrs servesMatrixDiscovery {

View file

@ -445,6 +445,43 @@ let
&& bare.services.hyperhive.swarm.serviceDomains != [ ] && bare.services.hyperhive.swarm.serviceDomains != [ ]
&& lib.hasInfix "want_svc=0" bare.systemd.services.swarm-services-cert.script; && 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-<oldhash>.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 # nixos asserts when a vhost declares both, so this is also a
# statement that the `removeAttrs` upstream of it still happens. # statement that the `removeAttrs` upstream of it still happens.