diff --git a/docs/gateway.md b/docs/gateway.md index fd86ef1f..6b65fe85 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -15,7 +15,6 @@ 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 76235dbd..41050375 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -49,51 +49,30 @@ 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 · 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 +### 4 · Matrix ```bash -# 5a. Ensure the hive-internal admin account exists first +# 4a. Ensure the hive-internal admin account exists first hivectl matrix sync-admin -# 5b. Provision ruth's own matrix account +# 4b. Provision ruth's own matrix account hivectl matrix create-user ruth -# 5c. Create a human matrix account +# 4c. Create a human matrix account hivectl matrix create-user mara --password hunter2 -# 5d. Invite the operator to the hive Space (and optionally to rooms) +# 4d. Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' -# 5e. Promote the operator to homeserver admin if needed +# 4e. Promote the operator to homeserver admin if needed hivectl matrix promote-user mara ``` -### 6 · Spawn sub-agents +### 5 · 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 @@ -114,7 +93,7 @@ request_init_config(name: "iris") See [`approvals.md`](approvals.md) for the full flow. -### 7 · Useful host commands +### 6 · 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 0022a0c3..fc6f3c79 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -94,12 +94,6 @@ 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 deleted file mode 100644 index 0bb73bcc..00000000 --- a/docs/swarm/ui.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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 37f2c26a..141187d8 100644 --- a/flake.nix +++ b/flake.nix @@ -148,9 +148,6 @@ 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 cbfd7028..56f21137 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -26,7 +26,6 @@ ./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 b573a93a..f8122f13 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -23,7 +23,6 @@ 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; @@ -71,7 +70,6 @@ let forgeCfg matrixCfg autheliaCfg - uiCfg hyperhiveDomain dashboardDist swaggerUiTheme @@ -310,7 +308,6 @@ in forgeCfg matrixCfg autheliaCfg - uiCfg hyperhiveDomain ; }; @@ -335,7 +332,6 @@ 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 cd56e226..d5096188 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -11,7 +11,6 @@ forgeCfg, matrixCfg, autheliaCfg, - uiCfg, hyperhiveDomain, }: { @@ -61,16 +60,7 @@ ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${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}"; + ++ lib.optional autheliaCfg.enable "/${autheliaCfg.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 cf4ecb4f..28acb246 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -10,7 +10,6 @@ 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 @@ -172,68 +171,6 @@ 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 @@ -521,6 +458,5 @@ in } // forgeVhost // autheliaVhost - // matrixVhost - // swarmUiVhost; + // matrixVhost; } diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 30d39fd5..8aca2d44 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -37,20 +37,6 @@ 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 @@ -550,26 +536,7 @@ in authentication_backend.file.path = cfg.usersFile; - # ⚠️ `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"; - }; - }; + access_control.default_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 deleted file mode 100644 index 18820162..00000000 --- a/nix/host-modules/swarm-ui.nix +++ /dev/null @@ -1,97 +0,0 @@ -# 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 d3502e45..4bf07940 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -39,13 +39,7 @@ 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 {