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
2 changed files with 181 additions and 19 deletions

View file

@ -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-<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;
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-<oldhash>.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

View file

@ -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-<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
# statement that the `removeAttrs` upstream of it still happens.