Adds an "/api/whoami" same-origin nginx proxy to authelia's own GET /api/user/info (session-cookie authenticated, no swarm-controller code needed) and a new UserMenu header component: a generated initials avatar (first letter of display name, coloured from the same seven base16 chromatic slots the nav accent already cycles through) opening a popover with the signed-in name, a link to authelia settings, and log out — both reusing the existing "Authelia" entry from GET /api/links rather than a second source of the domain. Per mara's call on the open avatar-mechanism question: initials now, a real uploaded photo (authelia's settings UI implies pics are settable) is an explicit future item, not blocking this.
274 lines
12 KiB
Nix
274 lines
12 KiB
Nix
# The swarm-level web UI: a static bundle served by the gateway's nginx,
|
|
# behind authelia. Distinct from the per-hive dashboard (hive-c0re's, on
|
|
# the hive domain) — this one answers for the swarm apex and is the
|
|
# operator's view across hives.
|
|
#
|
|
# Options only. The vhost itself is declared in hive-gateway/vhosts.nix
|
|
# alongside forge/matrix/authelia: a service module says *how* it is
|
|
# reached, the gateway says *whether this host serves it*.
|
|
{
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.swarm.ui;
|
|
gatewayCfg = config.services.hyperhive.gateway;
|
|
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
|
controllerCfg = config.services.hyperhive.swarm.controller;
|
|
|
|
# Repeated verbatim by every location that should be operator-gated
|
|
# (`/`, `/api/`, `/api/docs/`) rather than set once on the server:
|
|
# nginx's `auth_request` does NOT inherit across sibling locations, so
|
|
# setting it only on the page would leave this UI's own API and API
|
|
# docs reachable without a session.
|
|
swarmAuthRequest = ''
|
|
auth_request /__hive_authelia;
|
|
# Captured BEFORE the error_page jump: inside the 401 handler
|
|
# `$request_uri` is the internal one, so building the return
|
|
# link there sends the operator back to the auth subrequest
|
|
# instead of the page they asked for.
|
|
auth_request_set $target_url $scheme://$http_host$request_uri;
|
|
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
|
|
'';
|
|
swarmCfg = config.services.hyperhive.swarm;
|
|
hiveDomain = config.services.hyperhive.domain;
|
|
in
|
|
{
|
|
options.services.hyperhive.swarm.ui = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = swarmCfg.controller.enable;
|
|
defaultText = lib.literalExpression "services.hyperhive.swarm.controller.enable";
|
|
example = true;
|
|
description = ''
|
|
Serve the swarm UI from this host.
|
|
|
|
Derived from `swarm.controller.enable` rather than from
|
|
`enableRequiredServices`: the UI is a view onto the controller's
|
|
state and reaches it over that daemon's unix socket, so the host
|
|
that runs the controller is the host that can serve the UI. A
|
|
hive that merely *uses* a swarm has nothing to serve here.
|
|
'';
|
|
};
|
|
|
|
domain = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
|
|
defaultText = lib.literalExpression "services.hyperhive.swarm.domain";
|
|
example = "swarm.example.com";
|
|
description = ''
|
|
Host name the swarm UI answers on. Defaults to the swarm apex
|
|
itself — the swarm's front page is the swarm's name.
|
|
|
|
An option rather than a hardcoded derivation so a hive can pin a
|
|
different name, the same way `swarm.forge.domain` and
|
|
`swarm.matrix.gatewayHost` can.
|
|
|
|
Total on a null swarm domain (`.invalid`, RFC 2606) so the
|
|
required-domain assertion is what fires rather than a coercion
|
|
error naming this option — same reasoning as
|
|
`hive-network.nix`'s.
|
|
'';
|
|
};
|
|
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui";
|
|
description = ''
|
|
Static build of the swarm UI. nginx serves this store path
|
|
directly — there is no server-side component beyond the
|
|
controller's own API.
|
|
|
|
Wired by default from this flake's own package set (see
|
|
`flake.nix`), the same way `swarm.controller.package` is. There
|
|
is deliberately **no overlay** in this project, so a
|
|
`pkgs.swarm-ui` default here would name an attribute that does
|
|
not exist on any real deployment.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
|
assertions = [
|
|
{
|
|
# The `_` default server already answers for the hive domain
|
|
# (dashboard, per-agent routes). A second vhost claiming the same
|
|
# server_name is not an error to nginx — it picks one and logs a
|
|
# conflict — so the failure would surface as "the dashboard is
|
|
# sometimes the swarm UI", which is far harder to read than an
|
|
# eval failure naming both options.
|
|
assertion = cfg.domain != hiveDomain;
|
|
message = ''
|
|
services.hyperhive.swarm.ui.domain (${cfg.domain}) must differ
|
|
from services.hyperhive.domain (${hiveDomain}) — the hive
|
|
domain is already served by the gateway's default vhost
|
|
(dashboard + agent routes), and two vhosts claiming one
|
|
server_name silently resolve to whichever nginx picks.
|
|
|
|
Set services.hyperhive.swarm.domain to a name distinct from
|
|
this hive's, or pin swarm.ui.domain explicitly.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# The swarm UI's own gateway surface. Published to agents on the
|
|
# bridge deliberately: reachability is not the access control here —
|
|
# the `auth_request` below and authelia's `group:operators` rule
|
|
# are, and an agent that resolves the name still cannot open the
|
|
# page.
|
|
#
|
|
# The apex is a SIBLING of `forge.<swarm>` / `chat.<swarm>`, not a
|
|
# child of anything the resolver already answers for, so the
|
|
# `/<hive domain>/` rule does not cover it and this record is what
|
|
# makes the name resolve at all.
|
|
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
|
|
|
# This UI's own swagger docs, always same-origin (`/api/docs/` below)
|
|
# so — unlike authelia/matrix/forge's entries — this one needs no
|
|
# host name and is never conditional on anything but this module
|
|
# being enabled at all. See
|
|
# `services.hyperhive.swarm.controller.links`'s description.
|
|
services.hyperhive.swarm.controller.links = [
|
|
{
|
|
label = "API docs";
|
|
icon = "🧬";
|
|
url = "/api/docs/";
|
|
}
|
|
];
|
|
|
|
# The swarm's front page, and the FIRST `auth_request` anywhere in
|
|
# this gateway (everything else is `auth_basic` + htpasswd).
|
|
#
|
|
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
|
# operator". The operator-only part is authelia's `access_control`
|
|
# rule (./swarm-authelia.nix) requiring `group:operators` — agents
|
|
# have authelia accounts of their own, and without that rule a
|
|
# session alone would open this page.
|
|
#
|
|
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
|
|
# that wrongly denies takes the whole UI away. That is why the
|
|
# redirect target and the header set below come from a measured
|
|
# source rather than an example.
|
|
#
|
|
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a
|
|
# hardening preference, the only way this page works at all.
|
|
# authelia answers the auth subrequest for an `http://` target with
|
|
# **400**, and nginx's `auth_request` only understands 2xx/401/403,
|
|
# so a plain-http visit dies as "auth request unexpected status:
|
|
# 400" with no hint a login exists. The shared listen set binds :80,
|
|
# so without this the door is open on a port the lock cannot work
|
|
# on. Serving forge or matrix over http is merely insecure rather
|
|
# than broken, so they keep `addSSL` and the asymmetry stays local
|
|
# to the vhost whose correctness depends on the scheme.
|
|
# `removeAttrs` because nixos asserts on a vhost declaring both.
|
|
services.nginx.virtualHosts."${cfg.domain}" =
|
|
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
|
|
// {
|
|
forceSSL = true;
|
|
listen = gatewayCfg.lib.listen;
|
|
extraConfig = gatewayCfg.lib.securityHeaders;
|
|
locations = {
|
|
"/" = {
|
|
root = "${cfg.package}";
|
|
extraConfig = ''
|
|
${swarmAuthRequest}
|
|
# 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;
|
|
'';
|
|
};
|
|
# swarm-controller's whole HTTP surface, including the live
|
|
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
|
# after the socket path, same "pass the request through as-is"
|
|
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
|
# path swarm-controller registered a route at is the path
|
|
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
|
"/api/" = {
|
|
proxyPass = "http://unix:${controllerCfg.socketPath}:";
|
|
extraConfig = swarmAuthRequest;
|
|
};
|
|
# ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`,
|
|
# and that is deliberate rather than an omission.
|
|
#
|
|
# A forge webhook is a machine POST carrying an HMAC signature
|
|
# and no session cookie. An authelia auth-request subrequest
|
|
# authenticates a *browser session*; there is nothing here for it
|
|
# to check, so guarding this location would not make it safer, it
|
|
# would make it permanently unreachable.
|
|
#
|
|
# What replaces it: swarm-controller verifies the
|
|
# `X-Hub-Signature-256` HMAC over the raw body before looking at
|
|
# anything else, and answers 401 on any mismatch. That check is
|
|
# the access control for this path — see the `webhook` module.
|
|
#
|
|
# Scoped to `/webhook/forge/` rather than `/webhook/` so the
|
|
# carve-out is exactly as wide as the endpoint that justifies it:
|
|
# a future `/webhook/<something-else>` does not inherit the
|
|
# bypass by living under a shared prefix.
|
|
"/webhook/forge/" = {
|
|
proxyPass = "http://unix:${controllerCfg.socketPath}:";
|
|
};
|
|
# Swagger UI: same "nginx hosts the themed dist straight from
|
|
# the store, only /api/openapi.json is dynamic" shape as the
|
|
# per-hive gateway's `swaggerUiLocations`.
|
|
"= /api/docs" = {
|
|
extraConfig = ''
|
|
return 301 /api/docs/;
|
|
'';
|
|
};
|
|
# Session identity for the header's profile menu: initials
|
|
# avatar + "who is this" line. `auth_request` above
|
|
# only ever answers yes/no — it never forwards *who* — so the
|
|
# frontend has no other way to learn this. Same-origin proxy
|
|
# straight to authelia's own `GET /api/user/info`
|
|
# (session-cookie authenticated) rather than new
|
|
# swarm-controller code: the cookie is already valid here (the
|
|
# session cookie's domain is the swarm's, shared across every
|
|
# vhost under it — see swarm-authelia.nix), so this is a pure
|
|
# pass-through with nothing for a daemon to add.
|
|
#
|
|
# `=` exact match, not a prefix, so proxy_pass's own URI part
|
|
# (`/api/user/info`) REPLACES the matched request URI rather
|
|
# than being appended to it — same substitution shape as
|
|
# `/__hive_authelia` below, just not `internal` since the
|
|
# frontend calls this one directly.
|
|
"= /api/whoami" = {
|
|
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/user/info";
|
|
extraConfig = swarmAuthRequest;
|
|
};
|
|
"/api/docs/" = {
|
|
alias = "${gatewayCfg.swaggerUiTheme}/";
|
|
extraConfig = ''
|
|
index index.html;
|
|
${swarmAuthRequest}
|
|
'';
|
|
};
|
|
# The subrequest itself. `auth-request` is the implementation
|
|
# name authelia exposes under `/api/authz/`; `/api/verify` is
|
|
# the LEGACY path every older example shows.
|
|
#
|
|
# Header set measured against the pinned binary (4.39.20), not
|
|
# copied: `X-Original-URL` and `X-Original-Method` are present
|
|
# as literals and are what this implementation reads —
|
|
# `X-Forwarded-Uri` does not appear in it at all, so sending it
|
|
# would look like configuration and be dead weight.
|
|
"= /__hive_authelia" = {
|
|
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
|
|
extraConfig = ''
|
|
internal;
|
|
# A subrequest carries no body, and forwarding one here makes
|
|
# authelia read a payload it will never use.
|
|
proxy_pass_request_body off;
|
|
proxy_set_header Content-Length "";
|
|
proxy_set_header X-Original-Method $request_method;
|
|
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-Host $http_host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|