From f2840612c0003e1c3e5564cb4f4cc779d2933263 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:29:13 +0200 Subject: [PATCH 1/7] feat(3167): options + certificate name for the swarm UI New swarm-ui module: enable (derived from swarm.controller.enable - the UI reads that daemon's state over its socket, so the host that runs the controller is the host that can serve the UI), domain (defaults to the swarm apex; an option so a hive can pin it like forge/matrix can), and package. Adds the name to swarm.serviceDomains, which is both the services sub-CA's nameConstraints set and the leaf's SAN set. The apex is a SIBLING of forge./chat./auth., not a parent, so nothing issues for it implicitly - left out, the vhost falls back to the hive leaf and the swarm's front page opens with a name mismatch. Asserts the UI domain differs from the hive domain: the gateway's default server already answers for the latter, and two vhosts claiming one server_name resolve to whichever nginx picks rather than erroring. --- nix/host-modules/default.nix | 1 + nix/host-modules/swarm-ui.nix | 93 +++++++++++++++++++++++++++++++++++ nix/host-modules/swarm.nix | 8 ++- 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 nix/host-modules/swarm-ui.nix diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 56f21137..cbfd7028 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -26,6 +26,7 @@ ./swarm-ca.nix ./swarm-controller.nix ./swarm-snapshot-store.nix + ./swarm-ui.nix ./swarm-wireguard.nix ./swarm.nix ./swarm-peers-removed.nix diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix new file mode 100644 index 00000000..b1da263d --- /dev/null +++ b/nix/host-modules/swarm-ui.nix @@ -0,0 +1,93 @@ +# 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, + pkgs, + ... +}: +let + cfg = config.services.hyperhive.swarm.ui; + 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; + default = pkgs.swarm-ui; + defaultText = lib.literalExpression "pkgs.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. + ''; + }; + }; + + 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. + ''; + } + ]; + }; +} diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 4bf07940..d3502e45 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -39,7 +39,13 @@ let swarmCfg.forge.domain swarmCfg.matrix.gatewayHost swarmCfg.authelia.domain - ]; + ] + # The swarm UI's name is a SIBLING of the other three, not a parent of + # them — the apex is as much a name needing a certificate as + # `forge.` is, and no CA in the hierarchy issues for it + # implicitly. Left out, its vhost falls back to the hive leaf and the + # swarm's front page opens with a name mismatch. + ++ lib.optional swarmCfg.ui.enable swarmCfg.ui.domain; in { options.services.hyperhive.swarm.hives = lib.mkOption { From d337fec56567a963a568d6245548a784a4d214c4 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:31:30 +0200 Subject: [PATCH 2/7] feat(3167): authelia gates the swarm UI on an operators group default_policy = one_factor means 'any authenticated user', which is authentication and not authorisation. The swarm UI is operator-only and agents are getting authelia accounts of their own, so a session alone would be enough to open it the day that lands - the vhost's auth_request would be a check nobody fails. Adds an access_control rule for the UI's domain requiring subject = group:operators, present only when the UI is enabled. The group name is a constant beside the rule: it is also what an operator types into 'swarmctl user add --group', and a configurable name is one more way for the rule and the account to disagree silently. --- nix/host-modules/swarm-authelia.nix | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 8aca2d44..b314cd53 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -37,6 +37,13 @@ let hyperhiveCfg = config.services.hyperhive; hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; + uiCfg = hyperhiveCfg.swarm.ui; + + # Group an account must hold to reach operator-only surfaces. Named + # here because this module writes the rule that enforces it and + # `swarmctl user add --group ` is what grants it — the two must + # agree, and one constant is how they stay agreeing. + operatorGroup = "operators"; # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name @@ -536,7 +543,26 @@ in authentication_backend.file.path = cfg.usersFile; - access_control.default_policy = "one_factor"; + # ⚠️ `one_factor` as the DEFAULT means "any authenticated + # user", which is authentication, not authorisation. The + # swarm UI is operator-only, and agents are getting + # authelia accounts of their own — so the day that lands, + # a session alone would be enough to open it. The rule + # below is what makes the distinction real; without it + # the vhost's `auth_request` is a check nobody fails. + # + # The group is a constant rather than an option: it is the + # value an operator types into `swarmctl user add --group`, + # and a configurable name is one more way for the rule and + # the account to disagree silently. + access_control = { + default_policy = "one_factor"; + rules = lib.optional uiCfg.enable { + domain = uiCfg.domain; + subject = [ "group:${operatorGroup}" ]; + policy = "one_factor"; + }; + }; # The cookie domain is the SWARM's domain, NOT authelia's # own host: the session cookie has to be sent to the apps From 0dc2e6b64f5df47dc423e14d6494e36cbf1d874e Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:40:24 +0200 Subject: [PATCH 3/7] feat(3167): the swarm UI vhost, behind an authelia subrequest Serves the static bundle on the swarm apex and gates it with auth_request - the first one in this gateway, everything else being auth_basic + htpasswd. Header set measured against the pinned authelia (4.39.20) rather than copied from an example: X-Original-URL and X-Original-Method are present as literals and are what the auth-request implementation reads, while X-Forwarded-Uri does not appear in that binary at all - sending it would look like configuration and be dead weight. The endpoint is /api/authz/auth-request; /api/verify is the legacy path older examples show. auth_request_set captures the return URL BEFORE the error_page jump: in the 401 handler $request_uri is the internal one, so building the link there sends the operator back to the auth subrequest rather than the page they asked for. Authorisation is the access_control rule from the previous commit, not this subrequest: auth_request answers 'is there a session'. --- nix/host-modules/hive-gateway/default.nix | 2 + nix/host-modules/hive-gateway/vhosts.nix | 66 ++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index f8122f13..0addddfe 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -23,6 +23,7 @@ let swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; autheliaCfg = config.services.hyperhive.swarm.authelia; + uiCfg = config.services.hyperhive.swarm.ui; forgeCfg = config.services.hyperhive.swarm.forge; networkCfg = config.services.hyperhive.network; @@ -70,6 +71,7 @@ let forgeCfg matrixCfg autheliaCfg + uiCfg hyperhiveDomain dashboardDist swaggerUiTheme diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 28acb246..cf4ecb4f 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -10,6 +10,7 @@ forgeCfg, matrixCfg, autheliaCfg, # services.hyperhive.swarm.authelia + uiCfg, # services.hyperhive.swarm.ui hyperhiveDomain, dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css @@ -171,6 +172,68 @@ let }; }; + # Swarm UI vhost — the swarm's front page, on the swarm apex, 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 + # are getting 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 the reason the + # redirect target and the header set below are copied from a measured + # source rather than from an example. + swarmUiVhost = lib.optionalAttrs uiCfg.enable { + "${uiCfg.domain}" = (vhostTlsFor uiCfg.domain) // { + listen = vhostListen; + extraConfig = securityHeaders; + locations = { + "/" = { + root = "${uiCfg.package}"; + extraConfig = '' + 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; + # 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; + ''; + }; + # 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; + ''; + }; + }; + }; + }; + # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll # timeout). `/` serves fluffychat or 404 if GUI off. nginx @@ -458,5 +521,6 @@ in } // forgeVhost // autheliaVhost - // matrixVhost; + // matrixVhost + // swarmUiVhost; } From 600fa15afe09875188f27a85cbd834f85f60cf97 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:43:35 +0200 Subject: [PATCH 4/7] feat(3167): publish the swarm UI's name to agents and to local dev Four wiring sites, same as forge/matrix/authelia: vhost, cert name, DNS, local-dev hosts. This commit is the last two. The apex is a SIBLING of forge./chat./auth., not a child of anything dnsmasq already answers for, so the // rule does not cover it - without an explicit record the name is unresolvable from every agent netns while working fine from the host, which is the shape that nearly shipped when authelia's name moved. Published to agents deliberately (mara: publishing it is fine). Reachability is not the access control: the vhost's auth_request plus authelia's group:operators rule are, and an agent that resolves the name still cannot open the page. --- nix/host-modules/hive-gateway/default.nix | 2 ++ nix/host-modules/hive-gateway/dnsmasq.nix | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 0addddfe..b573a93a 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -310,6 +310,7 @@ in forgeCfg matrixCfg autheliaCfg + uiCfg hyperhiveDomain ; }; @@ -334,6 +335,7 @@ in ) config.services.hyperhive.swarm.forge.domain ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost ++ lib.optional autheliaCfg.enable autheliaCfg.domain + ++ lib.optional uiCfg.enable uiCfg.domain ); }; }; diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index d5096188..cd56e226 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -11,6 +11,7 @@ forgeCfg, matrixCfg, autheliaCfg, + uiCfg, hyperhiveDomain, }: { @@ -60,7 +61,16 @@ ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}" - ++ lib.optional autheliaCfg.enable "/${autheliaCfg.domain}/${networkCfg.bridgeIp}"; + ++ lib.optional autheliaCfg.enable "/${autheliaCfg.domain}/${networkCfg.bridgeIp}" + # The swarm UI's name is the swarm APEX by default — a sibling of + # the three above, not a child of anything this resolver already + # answers for, so the `//` rule does not cover it. + # + # Published to agents deliberately (mara: publishing it is fine). + # Reachability is not the access control here: the vhost's + # `auth_request` + authelia's `group:operators` rule are, and an + # agent that resolves the name still cannot open the page. + ++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}"; # DHCP pool covering all usable host addresses on the bridge # subnet — bounds computed by hive-network.nix from # bridgeIp/bridgePrefixLength. All containers (agents and service From 470d2ad845c2d0c1f0dd7b4f5d5d71fe0cf58d95 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:46:15 +0200 Subject: [PATCH 5/7] docs(3167): the swarm UI page, and the group step that gates it New docs/swarm/ui.md (split-page shape, per the docs rule), linked from the swarm README and added to the gateway's vhost map. Leads with the step that separates 'protected' from 'locked out': swarmctl user add --group operators. auth_request asks whether there is a session; the access_control rule is what makes it mean operator, and an account created before the rule existed has no groups. Also records the four wiring sites a swarm service name needs, with the certificate one called out - serviceDomains is both the sub-CA's nameConstraints set and the leaf's SANs, and the apex is a sibling of the other three rather than a parent, so nothing issues for it implicitly. --- docs/gateway.md | 1 + docs/swarm/README.md | 6 ++++ docs/swarm/ui.md | 80 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 docs/swarm/ui.md diff --git a/docs/gateway.md b/docs/gateway.md index 6b65fe85..fd86ef1f 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -15,6 +15,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `matrix./` | `matrix.` | fluffychat-web static | `matrix.gui.enable` | | `matrix./config.json` | `matrix.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | | `auth./` | `auth.` | authelia (`9091`) | `swarm.authelia.enable` | +| `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `swarm.ui.enable` | The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. diff --git a/docs/swarm/README.md b/docs/swarm/README.md index fc6f3c79..810d0213 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -94,6 +94,12 @@ Which secrets the SSO provider generates, which one has a reader in another container, and the three ways that one gets delivered: [`sso.md`](sso.md). +## Swarm UI + +The operator-only web surface on the swarm apex, why reaching it needs +the `operators` group rather than just a session, and the four sites a +swarm service name has to be wired into: [`ui.md`](ui.md). + ## The swarm's hive directory ```nix diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md new file mode 100644 index 00000000..cfdc46c6 --- /dev/null +++ b/docs/swarm/ui.md @@ -0,0 +1,80 @@ +# Swarm UI + +The swarm's own web surface, served by the gateway on the **swarm apex** +(`services.hyperhive.swarm.domain`) and readable only by operators. + +Distinct from the per-hive dashboard, which lives on the hive domain and +answers for one host. This one is the view *across* hives. + +## Enabling + +```nix +services.hyperhive.swarm.ui.enable = true; # defaults to swarm.controller.enable +``` + +Derived from the controller rather than from `enableRequiredServices`: +the UI is a view onto the controller's state and reaches it over that +daemon's 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. + +`swarm.ui.domain` defaults to the swarm apex and can be pinned, the same +way `swarm.forge.domain` and `swarm.matrix.gatewayHost` can. + +> The apex must differ from `services.hyperhive.domain`. The gateway's +> default server already answers for the hive domain, and two vhosts +> claiming one `server_name` do not error — nginx picks one — so this is +> an assertion rather than a runtime surprise. + +## 🔑 You must be in the `operators` group + +**This is the step that separates "protected" from "locked out".** The +vhost's `auth_request` asks authelia *"is there a session"*; the rule +that makes it mean *"is this an operator"* is an `access_control` entry +requiring `group:operators`. An account without that group authenticates +fine and still gets bounced. + +```sh +swarmctl user add --group operators +``` + +An account created before this existed has no groups. Re-add it with the +flag — `swarmctl` treats an existing entry as the canonical store, so the +group is what changes. + +Why a group and not a list of usernames: agents are getting authelia +accounts of their own (matrix SSO), and *authenticated* would then +include every agent in the hive. The group is the only thing standing +between "an operator's page" and "anyone with a session". + +## What it costs to be reachable + +The apex is published to the hive's resolver like every other swarm +service, so agent containers can resolve it. That is deliberate and it is +not a hole: **reachability is not the access control here.** An agent +that resolves the name and connects still has no operator session, and +the subrequest denies it. + +## Four wiring sites + +Adding a swarm service name means touching all four. Missing one ships as +a different flavour of "works from the host, broken from a container": + +| site | file | +| --- | --- | +| vhost | `nix/host-modules/hive-gateway/vhosts.nix` | +| **certificate name** | `nix/host-modules/swarm.nix` (`serviceDomains`) | +| DNS record | `nix/host-modules/hive-gateway/dnsmasq.nix` | +| local-dev hosts | `nix/host-modules/hive-gateway/default.nix` | + +⚠️ The certificate one is the least obvious and the most visible when +missed. `serviceDomains` is *both* the services sub-CA's +`nameConstraints` set and the leaf's SAN list, and the apex is a +**sibling** of `forge.` / `chat.` / `auth.`, not a +parent — no CA in the hierarchy issues for it implicitly. Left out, the +vhost falls back to the hive leaf and the swarm's front page opens with a +name mismatch. + +## Cross-references + +- [`sso.md`](sso.md) — the authelia instance itself, and the user store. +- [`../gateway.md`](../gateway.md) — the full vhost map and TLS modes. From 40e1ed29675ad7c9e2230867861ecc485df13ecb Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:49:21 +0200 Subject: [PATCH 6/7] fix(3167): wire the swarm-ui package from the flake, not a pkgs attr The option defaulted to pkgs.swarm-ui, which does not exist: this project has no overlay - flake.nix's nixosModules.default wires package options with mkDefault from its own package set, and swarm.controller does exactly that. The default would have failed to evaluate on any real deployment, not just in a test harness. Found by the gate forcing .package: the earlier probes passed because they only read option values that never touched it. --- flake.nix | 3 +++ nix/host-modules/swarm-ui.nix | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 141187d8..37f2c26a 100644 --- a/flake.nix +++ b/flake.nix @@ -148,6 +148,9 @@ services.hyperhive.swarm.controller.swarmctlPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarmctl; + services.hyperhive.swarm.ui.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-ui; services.hyperhive.gateway.swaggerUiTheme = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index b1da263d..18820162 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -9,7 +9,6 @@ { lib, config, - pkgs, ... }: let @@ -57,12 +56,17 @@ in package = lib.mkOption { type = lib.types.package; - default = pkgs.swarm-ui; - defaultText = lib.literalExpression "pkgs.swarm-ui"; + 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. ''; }; }; From 4dd5093c6f43837f65963f3b1d1c93c849174fcd Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 12 Aug 2026 17:57:52 +0200 Subject: [PATCH 7/7] docs(3167): the setup guide gains a swarm UI step, and one group name Per mara on the PR: the guide someone follows on a fresh deploy is a different page from the one they read while debugging, and only the second existed. Also fixes a contradiction the request surfaced: setup.md and swarm/sso.md have told every operator to create '--group admins' since the bootstrap step existed, while the new rule required 'operators'. This is the first rule that CONSUMES a group name, so it takes the one the guide already creates - inventing a second would have meant every account made by following the guide silently failing the check it was supposed to pass, and for mara specifically a migration step that now may not be needed at all. setup.md's step 3 says why --group is not decoration; the new step 4 says what decides whether the page opens (the group, and the name resolving) rather than listing a command, because there is nothing to run. Steps renumbered, including the matrix block's internal 4a-4e. --- docs/setup.md | 37 ++++++++++++++++++++++------- docs/swarm/README.md | 2 +- docs/swarm/ui.md | 19 ++++++++++----- nix/host-modules/swarm-authelia.nix | 9 ++++++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 41050375..76235dbd 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -49,30 +49,51 @@ start. Skipping this step looks like a broken proxy. swarmctl user add mara --display-name Mara --email mara@example.com --group admins ``` +⚠️ **Keep `--group admins`.** It is not decoration: operator-only +surfaces (the swarm UI below) are gated on that group, and an account +without it authenticates successfully and is then refused — which reads +like a broken login rather than a missing group. + Detail, including what the password is and why this stays manual: [`swarm/sso.md`](swarm/sso.md). -### 4 · Matrix +### 4 · Swarm UI (only when `swarm.ui.enable`, on by default with the controller) + +Nothing to run — it is served on the swarm apex +(`https:///`) as soon as the host rebuilds. Two things +decide whether you can actually open it: + +- **You are in `admins`** (step 3). The gateway asks authelia whether + you have a session; the rule that makes it mean *operator* wants the + group. Without it you log in and still get bounced. +- **The name resolves to this host.** It is published to the hive's own + resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from + anywhere else it needs a real DNS record like any other public name. + +Detail, including why reachability is deliberately not the access +control: [`swarm/ui.md`](swarm/ui.md). + +### 5 · Matrix ```bash -# 4a. Ensure the hive-internal admin account exists first +# 5a. Ensure the hive-internal admin account exists first hivectl matrix sync-admin -# 4b. Provision ruth's own matrix account +# 5b. Provision ruth's own matrix account hivectl matrix create-user ruth -# 4c. Create a human matrix account +# 5c. Create a human matrix account hivectl matrix create-user mara --password hunter2 -# 4d. Invite the operator to the hive Space (and optionally to rooms) +# 5d. Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' -# 4e. Promote the operator to homeserver admin if needed +# 5e. Promote the operator to homeserver admin if needed hivectl matrix promote-user mara ``` -### 5 · Spawn sub-agents +### 6 · Spawn sub-agents Sub-agent creation goes through the approval queue — ruth proposes, the operator approves, the container builds. From ruth's own turn (inside @@ -93,7 +114,7 @@ request_init_config(name: "iris") See [`approvals.md`](approvals.md) for the full flow. -### 6 · Useful host commands +### 7 · Useful host commands ```bash # Roster: all agents, status, rev, parent, pending reminders diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 810d0213..0022a0c3 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -97,7 +97,7 @@ another container, and the three ways that one gets delivered: ## Swarm UI The operator-only web surface on the swarm apex, why reaching it needs -the `operators` group rather than just a session, and the four sites a +the `admins` group rather than just a session, and the four sites a swarm service name has to be wired into: [`ui.md`](ui.md). ## The swarm's hive directory diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index cfdc46c6..0bb73bcc 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -25,21 +25,28 @@ way `swarm.forge.domain` and `swarm.matrix.gatewayHost` can. > claiming one `server_name` do not error — nginx picks one — so this is > an assertion rather than a runtime surprise. -## 🔑 You must be in the `operators` group +## 🔑 You must be in the `admins` group **This is the step that separates "protected" from "locked out".** The vhost's `auth_request` asks authelia *"is there a session"*; the rule that makes it mean *"is this an operator"* is an `access_control` entry -requiring `group:operators`. An account without that group authenticates +requiring `group:admins`. An account without that group authenticates fine and still gets bounced. ```sh -swarmctl user add --group operators +swarmctl user add --group admins ``` -An account created before this existed has no groups. Re-add it with the -flag — `swarmctl` treats an existing entry as the canonical store, so the -group is what changes. +`admins` deliberately, not a new word: [`../setup.md`](../setup.md) has +told every operator to create exactly that group since the bootstrap step +existed, so an account made by following the guide already passes. This +is the first rule that *consumes* a group name — inventing a second one +would have meant those accounts silently failing a check they were +supposed to pass. + +An account created without any group needs re-adding with the flag — +`swarmctl` treats the existing entry as the canonical store, so the group +is what changes. Why a group and not a list of usernames: agents are getting authelia accounts of their own (matrix SSO), and *authenticated* would then diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index b314cd53..30d39fd5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -43,7 +43,14 @@ let # here because this module writes the rule that enforces it and # `swarmctl user add --group ` is what grants it — the two must # agree, and one constant is how they stay agreeing. - operatorGroup = "operators"; + # + # ⚠️ `admins` and not a new word, because `docs/setup.md` and + # `docs/swarm/sso.md` have been telling every operator to create + # exactly that group since the bootstrap step existed. This is the + # first rule that CONSUMES a group name; picking a different one would + # have meant every account created by following the guide silently + # failing the check it was supposed to pass. + operatorGroup = "admins"; # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name