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/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 fc6f3c79..0022a0c3 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 `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 ```nix diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md new file mode 100644 index 00000000..0bb73bcc --- /dev/null +++ b/docs/swarm/ui.md @@ -0,0 +1,87 @@ +# 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 `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:admins`. An account without that group authenticates +fine and still gets bounced. + +```sh +swarmctl user add --group admins +``` + +`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 +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. 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/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/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index f8122f13..b573a93a 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 @@ -308,6 +310,7 @@ in forgeCfg matrixCfg autheliaCfg + uiCfg hyperhiveDomain ; }; @@ -332,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 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; } diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 8aca2d44..30d39fd5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -37,6 +37,20 @@ 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. + # + # ⚠️ `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 @@ -536,7 +550,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 diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix new file mode 100644 index 00000000..18820162 --- /dev/null +++ b/nix/host-modules/swarm-ui.nix @@ -0,0 +1,97 @@ +# 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; + 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. + ''; + } + ]; + }; +} 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 {