Compare commits

..
12 changed files with 12 additions and 345 deletions

View file

@ -15,7 +15,6 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next
| `matrix.<hive>/` | `matrix.<hive>` | fluffychat-web static | `matrix.gui.enable` |
| `matrix.<hive>/config.json` | `matrix.<hive>` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` |
| `auth.<swarm>/` | `auth.<swarm>` | authelia (`9091`) | `swarm.authelia.enable` |
| `<swarm>/` | `<swarm>` | 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.

View file

@ -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://<swarm.domain>/`) 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

View file

@ -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

View file

@ -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 <you> --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.<swarm>` / `chat.<swarm>` / `auth.<swarm>`, 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.

View file

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

View file

@ -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

View file

@ -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
);
};
};

View file

@ -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 `/<hive domain>/` 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

View file

@ -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;
}

View file

@ -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 <this>` 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.<name>` 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

View file

@ -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.
'';
}
];
};
}

View file

@ -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.<apex>` 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 {