Watch
0
0
Fork
You've already forked hyperhive
0

swarm options: no swarm.* value depends on a deploy option or this host's hive

- swarm.serviceDomains lists every swarm service name on every host. The
  store's issuing role is built from it, so the store now admits a leaf
  for grafana, metrics, otel, logs, the bao UI and the apex whichever host
  runs the store. Each gateway's own leaf stays narrowed by
  localServiceDomains.
- swarm.matrix.apiUrl defaults to http://<swarm.matrix.gatewayHost>
  (null when gatewayHost is null) instead of loopback on the matrix host.
- The authelia vhost proxies /bridge/ to swarm-authelia-bridge with the
  prefix stripped, and deploy.swarm-controller.authBridgeUrl defaults to
  ${swarm.authelia.url}/bridge on every host. swarm.authelia.bridgeUrl is
  removed.
- swarm-authelia-bridge admits only swarm-controller's OIDC client on
  every request (403 for any other active token), configured by
  SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID (renamed from
  ..._OPERATOR_READER_CLIENT_ID, which gated ListOperators alone).
- swarm.peerHives is removed; swarm-wireguard.nix filters this host's own
  hive out of swarm.hives itself.

Closes #4933
This commit is contained in:
atlas 2026-10-04 17:26:13 +02:00
commit bed4358e19
21 changed files with 226 additions and 283 deletions

View file

@ -82,10 +82,10 @@ pin the value before the homeserver mints any ids under the default.
## Default-closed firewall ## Default-closed firewall
`deploy.matrix.openFirewall` defaults to `false`: the host `deploy.matrix.openFirewall` defaults to `false`: hive-c0re and agent
reaches the homeserver on loopback, and agent containers reach it containers on every hive reach the homeserver at `chat.<swarm-domain>`
at `chat.<swarm-domain>` via the gateway — so the firewall hole only via the gateway, so the firewall hole only matters for direct access to
matters for access from *outside* the host. Flip to `true` when `httpPort` from *outside* the host. Flip to `true` when
announcing the homeserver to other hives or when an external matrix announcing the homeserver to other hives or when an external matrix
client needs to reach the client-server API directly. client needs to reach the client-server API directly.

View file

@ -14,6 +14,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
| --- | --- | --- | | --- | --- | --- |
| `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` | | `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` |
| `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` | | `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` |
| `auth.<swarm>/bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` |
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` | | `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` |
| `chat.<swarm>/_matrix/*` | tuwunel (`8008`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` | | `chat.<swarm>/_matrix/*` | tuwunel (`8008`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
| `chat.<swarm>/` | fluffychat-web static | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` | | `chat.<swarm>/` | fluffychat-web static | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |

View file

@ -472,9 +472,9 @@ services.hyperhive = {
host's routing table). host's routing table).
- It opens UDP port 51820 (or `listenPort`) on the host firewall. - It opens UDP port 51820 (or `listenPort`) on the host firewall.
- `swarm-wireguard.nix` reads each entry's `wireguardAddress` directly - `swarm-wireguard.nix` reads each entry's `wireguardAddress` directly
from `services.hyperhive.swarm.peerHives` to build `wg-hive`'s from `services.hyperhive.swarm.hives`, minus this host's own hive, to
`allowedIPs`, so intra-swarm traffic can route over the mesh address build `wg-hive`'s `allowedIPs`, so intra-swarm traffic can route over
rather than the public domain. the mesh address rather than the public domain.
- It sets `persistentKeepalive = 25` by default; override or null to - It sets `persistentKeepalive = 25` by default; override or null to
disable (not needed when both sides have public IPs and no NAT). disable (not needed when both sides have public IPs and no NAT).

View file

@ -54,7 +54,8 @@ copies from. Every hive does this with its own identity, so holding the
swarm root's private key stopped being what decides whether a hive can swarm root's private key stopped being what decides whether a hive can
serve its swarm's names. serve its swarm's names.
`services.hyperhive.swarm.serviceDomains` populates the role's `services.hyperhive.swarm.serviceDomains`, every swarm service name and the
same list on every host, populates the role's
`allowed_domains`, so asking for a name nobody configured is a refusal `allowed_domains`, so asking for a name nobody configured is a refusal
from the store naming that name — not a certificate quietly issued for from the store naming that name — not a certificate quietly issued for
it. A host's own SANs are narrower still: it asks only for the names it it. A host's own SANs are narrower still: it asks only for the names it

View file

@ -106,6 +106,14 @@ there is one IdP and one auth path.
Agent subjects come from swarm-controller's agent-creation job, written Agent subjects come from swarm-controller's agent-creation job, written
into the users database by `swarm-authelia-bridge`; human ones come from into the users database by `swarm-authelia-bridge`; human ones come from
`swarmctl user add` → [setup.md § 2](../getting-started/setup.md#2--your-sso-account). `swarmctl user add` → [setup.md § 2](../getting-started/setup.md#2--your-sso-account).
The bridge listens on loopback on the authelia host, and the gateway
publishes it as
`https://<swarm.authelia.domain>/bridge/`, which is where
`deploy.swarm-controller.authBridgeUrl` points by default on every host.
It answers only swarm-controller's OIDC client
(`swarm.controller.queueClientId`) and refuses every other token `403`.
On first boot this module seeds the users database with a disabled placeholder subject, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia generates session and storage keys in the container on On first boot this module seeds the users database with a disabled placeholder subject, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia generates session and storage keys in the container on
first boot and never rotates them automatically; replacing one first boot and never rotates them automatically; replacing one
invalidates data already written (sessions, the encrypted store), so invalidates data already written (sessions, the encrypted store), so

View file

@ -249,9 +249,10 @@ HTML instead of the failure that actually happened:
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client - `/api/oidc/token`, `/.well-known/openid-configuration` — a client
parsing an error page as its JSON document parsing an error page as its JSON document
The gateway routes authelia's `/api/` and `/.well-known/` prefixes The gateway routes authelia's `/api/` and `/.well-known/` prefixes, and
**without** error interception. The split is by _audience_, not by an enumerated path the bridge's `/bridge/`, **without** error interception. The split is by
list: a human gets the page, every machine caller gets the status. _audience_, not by an enumerated path list: a human gets the page, every
machine caller gets the status.
Enumerating endpoints individually would leave the next one added Enumerating endpoints individually would leave the next one added
silently intercepted. silently intercepted.

View file

@ -451,17 +451,16 @@ async fn handle_set_resource_limits(
/// Guard: matrix provisioning needs a homeserver to provision against. /// Guard: matrix provisioning needs a homeserver to provision against.
/// ///
/// Answers "is one configured", not "is one running here" — the message names /// Answers "is one configured", not "is one running here": the address is
/// both ways to get there, since a hive that talks to someone else's /// the swarm's homeserver, wherever it runs.
/// homeserver never enables the local container at all.
fn require_matrix_present() -> Result<()> { fn require_matrix_present() -> Result<()> {
if crate::matrix::is_present() { if crate::matrix::is_present() {
return Ok(()); return Ok(());
} }
anyhow::bail!( anyhow::bail!(
"no matrix homeserver configured — set services.hyperhive.deploy.matrix.enable = true to run one \ "no matrix homeserver configured — set services.hyperhive.swarm.matrix.apiUrl (it defaults \
here, or services.hyperhive.swarm.matrix.apiUrl to point at an existing one, before \ to the swarm.matrix.gatewayHost vhost, and is null when that is) before provisioning \
provisioning matrix users" matrix users"
) )
} }

View file

@ -127,9 +127,9 @@ in
./options.nix ./options.nix
./theme.nix ./theme.nix
# Hive-CA trust for this daemon's outbound TLS. Nothing it is given by # Hive-CA trust for this daemon's outbound TLS. Nothing it is given by
# default is https — the forge and matrix URLs resolve to plain http or # default is https — the forge and matrix URLs are plain http, and the
# loopback, and the queue's TLS is verified against # queue's TLS is verified against `HIVE_C0RE_OIDC_CA_FILE`
# `HIVE_C0RE_OIDC_CA_FILE` (../hive-tls.nix), not this bundle — so this # (../hive-tls.nix), not this bundle — so this
# changes nothing on an all-local hive. It matters for the split-host # changes nothing on an all-local hive. It matters for the split-host
# shape the options invite: `swarm.matrix.apiUrl`'s own example is # shape the options invite: `swarm.matrix.apiUrl`'s own example is
# `https://matrix.example.com`, and pointing it at another hive's # `https://matrix.example.com`, and pointing it at another hive's

View file

@ -180,15 +180,10 @@ in
// lib.optionalAttrs (config.services.hyperhive.swarm.matrix.apiUrl != null) { // lib.optionalAttrs (config.services.hyperhive.swarm.matrix.apiUrl != null) {
# Client-server API base hive-c0re uses to provision matrix (register # Client-server API base hive-c0re uses to provision matrix (register
# agent users, create the hive space + chat room, invite members). # agent users, create the hive space + chat room, invite members).
# Supplied by `services.hyperhive.swarm.matrix.apiUrl`, which the matrix # Supplied by `services.hyperhive.swarm.matrix.apiUrl`, which defaults to
# module fills in with its own loopback listener when it is the thing # the same gateway vhost as HIVE_MATRIX_URL above but is its own option,
# running tuwunel — and which the operator sets by hand when the # so an operator can point provisioning elsewhere without changing what
# homeserver lives on another machine. # agents are handed.
#
# NOT the agent-facing HIVE_MATRIX_URL above: that one is the gateway
# vhost, and it is absent whenever there is no vhost. Reusing it here
# would silently stop provisioning on a hive that runs matrix without
# one.
HIVE_MATRIX_API_URL = config.services.hyperhive.swarm.matrix.apiUrl; HIVE_MATRIX_API_URL = config.services.hyperhive.swarm.matrix.apiUrl;
} }
// { // {

View file

@ -10,7 +10,6 @@
let let
cfg = config.services.hyperhive.swarm.matrix; cfg = config.services.hyperhive.swarm.matrix;
swarmDomain = config.services.hyperhive.swarm.domain; swarmDomain = config.services.hyperhive.swarm.domain;
deployCfg = config.services.hyperhive.deploy;
in in
{ {
options.services.hyperhive.swarm.matrix = { options.services.hyperhive.swarm.matrix = {
@ -65,11 +64,10 @@ in
apiUrl = lib.mkOption { apiUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = if deployCfg.matrix.enable then "http://127.0.0.1:${toString cfg.httpPort}" else null; default = if cfg.gatewayHost == null then null else "http://${cfg.gatewayHost}";
defaultText = lib.literalExpression '' defaultText = lib.literalExpression ''
if services.hyperhive.deploy.matrix.enable if services.hyperhive.swarm.matrix.gatewayHost == null then null
then "http://127.0.0.1:''${toString services.hyperhive.swarm.matrix.httpPort}" else "http://''${services.hyperhive.swarm.matrix.gatewayHost}"
else null
''; '';
example = "https://matrix.example.com"; example = "https://matrix.example.com";
description = '' description = ''
@ -79,12 +77,10 @@ in
`hyperhive.matrix.url`, which is the gateway vhost handed to `hyperhive.matrix.url`, which is the gateway vhost handed to
each agent's `hive-matrix-daemon`. each agent's `hive-matrix-daemon`.
Defaults to the loopback listener **only when this module is the Defaults to the homeserver's gateway vhost (`gatewayHost`), the
thing running tuwunel** — in that case the address is not a same name on every hive whichever host runs tuwunel, and `null`
guess, it is where this module just put the container. Set it when there is no vhost. Set it explicitly when the homeserver is
explicitly (with `enable = false`) when the homeserver runs on reachable only some other way.
another machine; "everything on one host" is a special case of
the full deployment, not the assumption.
`null` means hive-c0re has no homeserver to provision against `null` means hive-c0re has no homeserver to provision against
and matrix provisioning no-ops. There is deliberately no and matrix provisioning no-ops. There is deliberately no

View file

@ -10,7 +10,6 @@
let let
cfg = config.services.hyperhive.swarm.authelia; cfg = config.services.hyperhive.swarm.authelia;
swarmDomain = config.services.hyperhive.swarm.domain; swarmDomain = config.services.hyperhive.swarm.domain;
deployCfg = config.services.hyperhive.deploy;
# The unit name upstream's `services.authelia.instances.<name>` derives, # The unit name upstream's `services.authelia.instances.<name>` derives,
# which `unit` publishes. ./swarm-authelia.nix binds the same two names for # which `unit` publishes. ./swarm-authelia.nix binds the same two names for
@ -432,32 +431,10 @@ in
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default (`127.0.0.1:''${bridgePort}`) — one above authelia's own default
`port` (9091), outside hyperhive's other claimed ranges. `port` (9091), outside hyperhive's other claimed ranges.
Reachable directly from this host's other processes (this Not published on its own: the authelia vhost proxies
container shares the host netns, same as authelia's own `port`) `/bridge/` to it, and that is the address `swarm-controller`
without going through the gateway — this is an internal uses from any host. The bridge answers only
service-to-service endpoint, not something meant to be exposed `swarm-controller`'s OIDC client.
publicly.
'';
};
bridgeUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
readOnly = true;
default = if deployCfg.authelia.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
description = ''
Where `swarm-authelia-bridge` answers, **as seen from this
host** — correct only when a caller (`swarm-controller`) also
runs on this host, the same co-location assumption
`swarm.nix`'s `clientSecretFile` documents for its own
cross-host case. `null` when this host doesn't run
`swarm-authelia` at all.
A split-host swarm has no automated delivery for this address:
the operator points `swarm-controller`'s own option at wherever
this host has made the bridge reachable (a firewall rule, a
different bind address), the same manual-copy shape used
throughout this codebase's other cross-host cases.
''; '';
}; };
}; };

View file

@ -759,6 +759,13 @@ in
locations."/.well-known/" = { locations."/.well-known/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
}; };
# `swarm-authelia-bridge`, a separate process from authelia, also a
# machine surface. The trailing slash on `proxyPass` strips the
# prefix, so `/bridge/requests` reaches the bridge as `/requests`.
# The bridge itself refuses every client but swarm-controller's.
locations."/bridge/" = {
proxyPass = "http://127.0.0.1:${toString cfg.bridgePort}/";
};
}; };
# Order the container after the host CA generator, so the bind source # Order the container after the host CA generator, so the bind source
@ -924,10 +931,10 @@ in
# includes `bridgeClient`) — same file this container's own # includes `bridgeClient`) — same file this container's own
# `renderClient` reads the digest half of. # `renderClient` reads the digest half of.
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret"; SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
# The operator list names who holds operator rights, so the # The one client the bridge answers. Every other active token
# bridge answers it to the controller's client and refuses every # (each hive and its agents hold one) is refused, on every
# other active token (each hive and its agents hold one). # request, since `/bridge/` is reachable from any host.
SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId; SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup; SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
}; };
}; };

View file

@ -587,17 +587,14 @@ in
authBridgeUrl = lib.mkOption { authBridgeUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = if deployCfg.authelia.enable then autheliaCfg.bridgeUrl else null; default = "${autheliaCfg.url}/bridge";
defaultText = lib.literalExpression '' defaultText = lib.literalExpression ''"''${services.hyperhive.swarm.authelia.url}/bridge"'';
authelia's own `bridgeUrl` when this host also runs example = "https://auth.example.com/bridge";
`swarm-authelia`, else null
'';
example = "http://127.0.0.1:9092";
description = '' description = ''
Where `swarm-authelia-bridge` (the only writer of authelia's Where `swarm-authelia-bridge` (the only writer of authelia's
users database) answers — see that option's own doc comment for users database) answers. The default is the `/bridge/` location
the cross-host caveat, since this default is only correct when on the authelia vhost, the same address from every host whether
this host also runs `swarm-authelia`. or not it runs `swarm-authelia`; the daemon appends `/requests`.
No new credential to configure: the bearer token presented to No new credential to configure: the bearer token presented to
the bridge is minted from THIS daemon's own existing queue OIDC the bridge is minted from THIS daemon's own existing queue OIDC
@ -879,8 +876,7 @@ in
environment = { environment = {
SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath; SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath;
# The swarm's hive directory, JSON-encoded — the full directory # The swarm's hive directory, JSON-encoded — the full directory
# (this daemon has no "self" hive to exclude, unlike # (this daemon has no "self" hive to exclude) rather than
# `swarm.peerHives`, `swarm.hives` minus this hive) rather than
# peers-minus-self. Consumed by `GET /api/hives` # peers-minus-self. Consumed by `GET /api/hives`
# (swarm-controller/src/main.rs::load_hives). # (swarm-controller/src/main.rs::load_hives).
# The one blacklist of names an agent may not take, shared verbatim # The one blacklist of names an agent may not take, shared verbatim

View file

@ -114,12 +114,14 @@
networking.wireguard.interfaces = networking.wireguard.interfaces =
let let
wgCfg = config.services.hyperhive.deploy.wireguard; wgCfg = config.services.hyperhive.deploy.wireguard;
# `peerHives` is `swarm.hives` minus this hive (../swarm.nix) — # `swarm.hives` minus this host's own hive: a mesh that included
# a mesh that included our own entry would configure a tunnel to # our own entry would configure a tunnel to ourselves.
# ourselves.
meshPeers = lib.filterAttrs ( meshPeers = lib.filterAttrs (
_: p: p.wireguardPublicKey != null && p.wireguardAddress != null name: p:
) config.services.hyperhive.swarm.peerHives; name != config.services.hyperhive.hiveName
&& p.wireguardPublicKey != null
&& p.wireguardAddress != null
) config.services.hyperhive.swarm.hives;
in in
{ {
wg-hive = { wg-hive = {

View file

@ -1,6 +1,6 @@
# The swarm's directory: one entry per hive, **including this one**, # The swarm's directory: one entry per hive, **including this one**,
# identical on every host in the swarm. `services.hyperhive.hiveName` # identical on every host in the swarm. `services.hyperhive.hiveName`
# says which entry is us, and `peerHives` below derives the rest. # says which entry is us.
# #
# Why a directory rather than a per-host peer list: every field here is # Why a directory rather than a per-host peer list: every field here is
# intrinsic to the hive it describes — none of them says anything about # intrinsic to the hive it describes — none of them says anything about
@ -78,41 +78,31 @@ let
# assertion encoded an intended shape, not a property of the code, and # assertion encoded an intended shape, not a property of the code, and
# it rejected the supported migration path: a hive pinning its old # it rejected the supported migration path: a hive pinning its old
# `forge.<hive domain>` while joining a swarm at a different apex. # `forge.<hive domain>` while joining a swarm at a different apex.
#
# Every name is listed whether or not this host runs the service: the
# store's issuing role is built from this list on the store's host and
# has to admit a leaf for any gateway in the swarm. What one gateway's
# leaf carries is `localServiceDomains`.
#
# Membership is also what `gateway.lib.tlsFor` consults to pick the
# services leaf over the hive one, so a gateway-published swarm name
# missing here is served the hive certificate, which cannot cover a name
# under a different apex.
serviceDomains' = [ serviceDomains' = [
swarmCfg.forge.domain swarmCfg.forge.domain
swarmCfg.matrix.gatewayHost swarmCfg.matrix.gatewayHost
swarmCfg.authelia.domain swarmCfg.authelia.domain
] # The swarm UI's apex is a sibling of the other names, not a parent:
# The swarm UI's name is a SIBLING of the other three, not a parent of # no CA in the hierarchy issues for it implicitly.
# them — the apex is as much a name needing a certificate as swarmCfg.domain
# `forge.<apex>` is, and no CA in the hierarchy issues for it swarmCfg.grafana.domain
# implicitly. Left out, its vhost falls back to the hive leaf and the swarmCfg.victoriametrics.domain
# swarm's front page opens with a name mismatch. swarmCfg.otel.domain
++ lib.optional (deployCfg.swarm-ui.enable && swarmCfg.domain != null) swarmCfg.domain swarmCfg.victorialogs.domain
# Every swarm service that claims a gateway name belongs here, and
# these three were missing it. Membership is what `gateway.lib.tlsFor`
# consults to pick the services leaf over the hive one, so a name
# absent from this list is served the HIVE certificate — which cannot
# cover a name under a different apex however the sub-CA is set up.
#
# ⚠️ How that stayed invisible: a mismatch a browser shows as a
# click-through warning is one a machine client rejects outright. The
# metrics UI and store looked fine for as long as only people opened
# them; the collector's exporter — same defect, no human in the loop —
# failed every POST and dropped the samples.
++ lib.optional deployCfg.grafana.enable swarmCfg.grafana.domain
++ lib.optional deployCfg.victoriametrics.enable swarmCfg.victoriametrics.domain
++ lib.optional deployCfg.swarm-otel.enable swarmCfg.otel.domain
# VictoriaLogs' vhost is new (was previously unpublished entirely — see
# swarm-victorialogs.nix's file-top comment) and needs the same
# membership every gateway-published swarm service needs: absent from
# this list, `gateway.lib.tlsFor` falls back to the hive leaf, which
# cannot cover a name under a different apex — see the ⚠️ above this
# list for what that looked like the last time a name was missed here.
++ lib.optional deployCfg.victorialogs.enable swarmCfg.victorialogs.domain
# The store's browser UI, not the store: `swarm.bao.domain` is mTLS and # The store's browser UI, not the store: `swarm.bao.domain` is mTLS and
# never gets a gateway certificate. # never gets a gateway certificate.
++ lib.optional deployCfg.bao.enable swarmCfg.bao.ui.domain; swarmCfg.bao.ui.domain
];
# Hives whose entry still carries the removed `certFingerprint`. Scanned # Hives whose entry still carries the removed `certFingerprint`. Scanned
# here, at top level, because that is the only place an assertion about a # here, at top level, because that is the only place an assertion about a
@ -274,28 +264,17 @@ in
''; '';
}; };
options.services.hyperhive.swarm.peerHives = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified);
readOnly = true;
internal = true;
description = ''
Read-only: `hives` minus this host's own entry. Derived once here
rather than in each consumer, because "everything that isn't me"
is a filter four different modules were re-implementing and only
one of them has to be wrong for a hive to peer with itself.
'';
};
options.services.hyperhive.swarm.serviceDomains = lib.mkOption { options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
readOnly = true; readOnly = true;
internal = true; internal = true;
description = '' description = ''
Read-only: the public hostnames of the swarm's own services, in a Read-only: the public hostnames of every swarm service, in a
stable sorted order. Second derived set alongside `peerHives`, and stable sorted order, identical on every host whichever services it
here for the same reason — the CA that name-constrains these and runs. Derived once here because the CA that name-constrains these,
the leaf that carries them as SANs must agree exactly, and two the store role that issues for them and the leaf that carries them
modules each assembling the list is how they stop agreeing. as SANs must agree exactly, and two modules each assembling the
list is how they stop agreeing.
Sorted and deduplicated deliberately: consumers compare this list Sorted and deduplicated deliberately: consumers compare this list
against what they issued last time to decide whether to re-issue, against what they issued last time to decide whether to re-issue,
@ -326,8 +305,6 @@ in
}; };
config = { config = {
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) ( services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
lib.unique (lib.filter (d: d != null && d != "") serviceDomains') lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
); );

View file

@ -197,6 +197,22 @@ let
&& !(lib.hasInfix "auth.t.local" u) && !(lib.hasInfix "auth.t.local" u)
&& lib.elem "auth.t.local" s; && lib.elem "auth.t.local" s;
} }
{
# The store's issuing role is built from this list on the store's host,
# so it cannot depend on which services the evaluating host runs. The
# last conjunct is the control: grafana runs on neither `bare` nor the
# forge host, and is still listed.
name = "the swarm's service names are the same on hosts running different services";
ok =
let
s = h: h.services.hyperhive.swarm.serviceDomains;
in
s (hive {
deploy.forgejo.enable = true;
}) == s bare
&& s allLocal == s bare
&& lib.elem "grafana.t.local" (s bare);
}
{ {
# The narrowing's floor: no names left means no request, rather than a # The narrowing's floor: no names left means no request, rather than a
# request for an empty SAN set that the store would refuse. Its own # request for an empty SAN set that the store would refuse. Its own

View file

@ -264,19 +264,33 @@ let
&& !(lib.hasInfix ''echo "users: {}"'' s); && !(lib.hasInfix ''echo "users: {}"'' s);
} }
{ {
# The bridge refuses its operator list to every client but the one # The bridge refuses every client but the one named here, so this has
# named here, so this has to be the client the controller authenticates # to be the client the controller authenticates as. A drift is a
# as. A drift is a controller whose admin sync is refused every pass. # controller whose every bridge call is refused.
name = "the authelia bridge answers its operator list to the controller's client only"; name = "the authelia bridge answers the controller's client only";
ok = ok =
let let
env = env =
autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment; autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment;
in in
env.SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID env.SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID
== autheliaColocated.services.hyperhive.swarm.controller.queueClientId == autheliaColocated.services.hyperhive.swarm.controller.queueClientId
&& env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins"; && env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins";
} }
{
# One address from every host, and a location the IdP host serves: a
# controller off the authelia host has no other way to the bridge.
name = "the auth bridge address is the authelia vhost's /bridge/ on every host";
ok =
let
url = h: h.services.hyperhive.deploy.swarm-controller.authBridgeUrl;
authelia = autheliaColocated.services.hyperhive.swarm.authelia;
location = autheliaColocated.services.nginx.virtualHosts.${authelia.domain}.locations."/bridge/";
in
url autheliaNotColocated == "${autheliaNotColocated.services.hyperhive.swarm.authelia.url}/bridge"
&& url autheliaColocated == url autheliaNotColocated
&& location.proxyPass == "http://127.0.0.1:${toString authelia.bridgePort}/";
}
{ {
# Config NAMES the IdP; it never computes where the IdP is. Both arms # Config NAMES the IdP; it never computes where the IdP is. Both arms
# matter together: the address is the swarm's name on a hive that runs # matter together: the address is the swarm's name on a hive that runs

View file

@ -25,18 +25,17 @@ simply owns the file it writes. No elevated privilege anywhere.
`ListOperators` (the operator group's usernames). `ListOperators` (the operator group's usernames).
- Bearer-authenticated via authelia's own OIDC token introspection (RFC - Bearer-authenticated via authelia's own OIDC token introspection (RFC
7662), against `swarm-controller`'s **existing** machine-client identity 7662), against `swarm-controller`'s **existing** machine-client identity
(already minted for the queue connection) — no new credential. The two (already minted for the queue connection) — no new credential. ⚠️ Every
agent verbs admit any active token. ⚠️ `ListOperators` admits only the request admits only the client named by
client named by `SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID` `SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID` (`swarm-controller`'s) and
(`swarm-controller`'s) and answers every other active token `403`: answers every other active token `403`: authelia issues machine tokens to
authelia issues machine tokens to every hive and its agents too. every hive and its agents too.
- No restart of authelia after a write — relies on - No restart of authelia after a write — relies on
`authentication_backend.file.watch`, confirmed working against the `authentication_backend.file.watch`, confirmed working against the
pinned 4.39.20 build during this design work. pinned 4.39.20 build during this design work.
- Network-facing rather than unix-socket-only: `swarm-authelia`'s container - Listens on loopback. The authelia vhost proxies `/bridge/` to it with
shares the host netns, so the same listener serves both a co-located the prefix stripped, so `swarm-controller` on any host calls
`swarm-controller` (loopback) and a split-host one (bind wider + firewall) `https://<swarm.authelia.domain>/bridge/requests`.
with no separate transport.
## One store, two writers ## One store, two writers

View file

@ -11,13 +11,10 @@
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see //! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
//! `swarmctl/README.md`'s own identical analysis of this same file). //! `swarmctl/README.md`'s own identical analysis of this same file).
//! //!
//! Network-facing, not unix-socket-only: `swarm-authelia`'s container //! Listens on loopback; the authelia vhost proxies `/bridge/` to it, so a
//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/ //! `swarm-controller` on any host reaches it at the same address.
//! `hive-matrix`), so one listener serves a co-located `swarm-controller`
//! (loopback) and a split-host one (bind wider, firewall it) with the
//! same code path — no separate transport for the cross-host case.
//! Bearer-authenticated via authelia's own OIDC token introspection (RFC //! Bearer-authenticated via authelia's own OIDC token introspection (RFC
//! 7662) against `swarm-controller`'s **existing** machine-client //! 7662), and admits only `swarm-controller`'s **existing** machine-client
//! identity (already minted for the queue connection) — no new //! identity (already minted for the queue connection) — no new
//! credential to mint or deliver, and always local/fast to verify since //! credential to mint or deliver, and always local/fast to verify since
//! this process runs right next to the authelia it asks. //! this process runs right next to the authelia it asks.
@ -25,7 +22,8 @@
//! One endpoint, `POST /requests`, body = //! One endpoint, `POST /requests`, body =
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent //! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
//! exists as an authelia subject, list the ones that do, and list the //! exists as an authelia subject, list the ones that do, and list the
//! operator group (to `swarm-controller` only, see `permit`). Not a //! operator group. Every request is answered to `swarm-controller` only,
//! see `classify`. Not a
//! wholesale-replace-the-file API — this process owns rendering //! wholesale-replace-the-file API — this process owns rendering
//! `users.yml` internally; see `store` module. //! `users.yml` internally; see `store` module.
@ -53,8 +51,8 @@ struct Config {
introspection_url: String, introspection_url: String,
client_id: String, client_id: String,
client_secret: String, client_secret: String,
/// The one client [`BridgeRequest::ListOperators`] answers. /// The one client this bridge answers: `swarm-controller`'s.
operator_reader_client_id: String, controller_client_id: String,
/// The authelia group whose members are operators. /// The authelia group whose members are operators.
operator_group: String, operator_group: String,
} }
@ -68,7 +66,7 @@ impl Config {
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?, introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?, client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?, client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
operator_reader_client_id: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID")?, controller_client_id: env_var("SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID")?,
operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?, operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?,
}) })
} }
@ -146,28 +144,20 @@ async fn handle_request(
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<BridgeRequest>, Json(req): Json<BridgeRequest>,
) -> Response { ) -> Response {
let caller = match authorize(&state, &headers).await { if let Err(resp) = authorize(&state, &headers).await {
Ok(caller) => caller, return resp;
Err(resp) => return resp, }
}; respond(&state.config, &state.write_lock, req).await
respond(&state.config, &state.write_lock, caller.as_deref(), req).await
} }
/// Everything after authentication: the per-request [`permit`] check, then /// Everything after authentication: the operation itself. Takes the config
/// the operation. Takes the config and the write lock rather than /// and the write lock rather than [`AppState`], whose HTTP client only
/// [`AppState`], whose HTTP client only [`authorize`] uses. /// [`authorize`] uses.
async fn respond( async fn respond(
config: &Config, config: &Config,
write_lock: &tokio::sync::Mutex<()>, write_lock: &tokio::sync::Mutex<()>,
caller: Option<&str>,
req: BridgeRequest, req: BridgeRequest,
) -> Response { ) -> Response {
if let Err(refusal) = permit(&req, caller, &config.operator_reader_client_id) {
// The client id is not a credential; naming it is what lets an
// operator see which client asked for something it may not have.
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
return refuse(refusal);
}
let (op, result) = match req { let (op, result) = match req {
BridgeRequest::EnsureAgentIdentity { name } => { BridgeRequest::EnsureAgentIdentity { name } => {
("ensure_identity", handle(config, write_lock, name).await) ("ensure_identity", handle(config, write_lock, name).await)
@ -194,10 +184,10 @@ async fn respond(
/// "the op itself failed," a different case from "the caller was never let /// "the op itself failed," a different case from "the caller was never let
/// in." /// in."
/// ///
/// Anything short of an active token is refused; **which status** depends /// Anything short of an active token from the controller's client is
/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the /// refused; **which status** depends on whose credential failed, see
/// token was issued to, for [`permit`]. /// [`Refusal`].
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<String>, Response> { async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(refuse(Refusal::Caller)); return Err(refuse(Refusal::Caller));
}; };
@ -215,41 +205,37 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<Strin
token, token,
) )
.await; .await;
classify(verdict).map_err(refuse) classify(verdict, &state.config.controller_client_id).map_err(refuse)
} }
/// The admission decision, and the only place that makes it. /// The admission decision, and the only place that makes it.
/// ///
/// Fail-closed: `Ok(())` is reachable from exactly one variant. Split out /// Fail-closed: `Ok(())` is reachable only from an active token issued to
/// of [`authorize`] so the rule is testable without an authelia — and it is /// `controller`. authelia also issues machine tokens to every hive and every
/// the *real* rule, not a restatement of it, because this is the function /// hive's agents, and those are as active as the controller's, so they are
/// the request path calls. /// refused `403`; a token attributed to no client is refused too, rather
fn classify(verdict: introspect::Verdict) -> Result<Option<String>, Refusal> { /// than compared as a blank. Split out of [`authorize`] so the rule is
/// testable without an authelia — and it is the *real* rule, not a
/// restatement of it, because this is the function the request path calls.
fn classify(verdict: introspect::Verdict, controller: &str) -> Result<(), Refusal> {
match verdict { match verdict {
introspect::Verdict::Active { client_id } => Ok(client_id), introspect::Verdict::Active { client_id } if client_id.as_deref() == Some(controller) => {
Ok(())
}
introspect::Verdict::Active { client_id } => {
// The client id is not a credential; naming it is what lets an
// operator see which client was refused.
tracing::warn!(
?client_id,
"refused an active token issued to another client"
);
Err(Refusal::Forbidden)
}
introspect::Verdict::Inactive => Err(Refusal::Caller), introspect::Verdict::Inactive => Err(Refusal::Caller),
introspect::Verdict::Unavailable => Err(Refusal::Bridge), introspect::Verdict::Unavailable => Err(Refusal::Bridge),
} }
} }
/// Whether an admitted caller may make this particular request.
///
/// [`BridgeRequest::ListOperators`] answers only `operator_reader`, the
/// controller's client: authelia also issues machine tokens to every hive and
/// every hive's agents, and those are as active as the controller's. A caller
/// with no client id is refused it too, rather than compared as a blank.
fn permit(
req: &BridgeRequest,
client_id: Option<&str>,
operator_reader: &str,
) -> Result<(), Refusal> {
match req {
BridgeRequest::EnsureAgentIdentity { .. } | BridgeRequest::ListAgentIdentities => Ok(()),
BridgeRequest::ListOperators if client_id == Some(operator_reader) => Ok(()),
BridgeRequest::ListOperators => Err(Refusal::Forbidden),
}
}
/// Whose credential was at fault when a request was refused. /// Whose credential was at fault when a request was refused.
/// ///
/// The distinction is the whole point: these used to be one 401, so a /// The distinction is the whole point: these used to be one 401, so a
@ -262,13 +248,14 @@ enum Refusal {
/// This bridge could not get an answer out of authelia at all, so the /// This bridge could not get an answer out of authelia at all, so the
/// request was never evaluated. /// request was never evaluated.
Bridge, Bridge,
/// The token is valid, but its client may not make this request. /// The token is valid, but was issued to a client other than the
/// controller's.
Forbidden, Forbidden,
} }
impl Refusal { impl Refusal {
/// `401` for the caller; `503` for us; `403` for a valid caller asking /// `401` for the caller; `503` for us; `403` for a valid token issued
/// for something scoped to another client. /// to a client other than the controller's.
/// ///
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing /// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
/// about the caller's credential was determined — and it is also the /// about the caller's credential was determined — and it is also the
@ -456,34 +443,58 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, permit, respond, BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, respond,
}; };
const CONTROLLER: &str = "swarm-controller";
fn active(client_id: Option<&str>) -> Verdict {
Verdict::Active {
client_id: client_id.map(str::to_owned),
}
}
/// Fail-closed, asserted per variant rather than as one negation: /// Fail-closed, asserted per variant rather than as one negation:
/// splitting blame out of a single denial is exactly the change that /// splitting blame out of a single denial is exactly the change that
/// could have widened admission, so each refusing variant is named on /// could have widened admission, so each refusing variant is named on
/// its own line. /// its own line.
#[test] #[test]
fn only_an_active_token_is_admitted() { fn only_an_active_token_is_admitted() {
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
assert_eq!( assert_eq!(
classify(Verdict::Active { classify(Verdict::Inactive, CONTROLLER),
client_id: Some("swarm-controller".to_owned())
}),
Ok(Some("swarm-controller".to_owned()))
);
assert_eq!(
classify(Verdict::Inactive),
Err(Refusal::Caller), Err(Refusal::Caller),
"authelia evaluated the token and said no" "authelia evaluated the token and said no"
); );
assert_eq!( assert_eq!(
classify(Verdict::Unavailable), classify(Verdict::Unavailable, CONTROLLER),
Err(Refusal::Bridge), Err(Refusal::Bridge),
"a bridge that cannot ask must not admit — this variant reports a \ "a bridge that cannot ask must not admit — this variant reports a \
different fault, it is not a softer denial" different fault, it is not a softer denial"
); );
} }
/// The trust boundary. Every caller below holds a token authelia calls
/// active — a hive and a hive's agents are issued machine tokens too —
/// and only the controller's client is admitted. The first assertion is
/// the control: the same verdict shape with the controller's id passes.
#[test]
fn only_the_controllers_client_is_admitted() {
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
assert_eq!(
classify(active(Some(other)), CONTROLLER),
Err(Refusal::Forbidden),
"{other} holds an active token but is not the controller"
);
}
assert_eq!(
classify(active(None), CONTROLLER),
Err(Refusal::Forbidden),
"a token authelia attributed to no client is not the controller's"
);
}
/// The whole point of the split, asserted on the value rather than /// The whole point of the split, asserted on the value rather than
/// on message text — the previous code picked its status by matching on /// on message text — the previous code picked its status by matching on
/// a log message's wording, which is what let the two collapse. /// a log message's wording, which is what let the two collapse.
@ -521,51 +532,8 @@ mod tests {
} }
} }
const CONTROLLER: &str = "swarm-controller";
/// The trust boundary on the operator list. Every caller below holds a
/// token authelia calls active — a hive and a hive's agents are issued
/// machine tokens too — and only the controller's client is answered.
#[test] #[test]
fn only_the_controller_may_list_operators() { fn a_refused_client_is_403_not_401() {
let req = BridgeRequest::ListOperators;
assert_eq!(permit(&req, Some(CONTROLLER), CONTROLLER), Ok(()));
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
assert_eq!(
permit(&req, Some(other), CONTROLLER),
Err(Refusal::Forbidden),
"{other} holds an active token but is not the controller"
);
}
assert_eq!(
permit(&req, None, CONTROLLER),
Err(Refusal::Forbidden),
"a token authelia attributed to no client is not the controller's"
);
}
/// The control for the test above: the agent verbs stay open to any
/// admitted caller, so the refusal there comes from the scoping and not
/// from `permit` refusing everyone who is not the controller.
#[test]
fn the_agent_verbs_stay_open_to_any_active_caller() {
for req in [
BridgeRequest::EnsureAgentIdentity {
name: "atlas".to_owned(),
},
BridgeRequest::ListAgentIdentities,
] {
assert_eq!(
permit(&req, Some("hive-alpha"), CONTROLLER),
Ok(()),
"{req:?}"
);
assert_eq!(permit(&req, None, CONTROLLER), Ok(()), "{req:?}");
}
}
#[test]
fn a_scoped_refusal_is_403_not_401() {
assert_eq!( assert_eq!(
Refusal::Forbidden.status(), Refusal::Forbidden.status(),
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@ -598,29 +566,16 @@ mod tests {
introspection_url: String::new(), introspection_url: String::new(),
client_id: String::new(), client_id: String::new(),
client_secret: String::new(), client_secret: String::new(),
operator_reader_client_id: CONTROLLER.to_owned(), controller_client_id: CONTROLLER.to_owned(),
operator_group: "admins".to_owned(), operator_group: "admins".to_owned(),
} }
} }
/// The request path, not only [`permit`]: a dispatch that skipped the
/// check would answer the hive below with the operator list.
#[tokio::test] #[tokio::test]
async fn the_request_path_answers_the_operator_list_to_the_controller_only() { async fn the_operator_list_is_the_operator_groups_members() {
let config = config_with_one_operator(); let config = config_with_one_operator();
let lock = tokio::sync::Mutex::new(()); let lock = tokio::sync::Mutex::new(());
for caller in [Some("hive-alpha"), None] { let answered = respond(&config, &lock, BridgeRequest::ListOperators).await;
let refused = respond(&config, &lock, caller, BridgeRequest::ListOperators).await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{caller:?}");
}
let answered = respond(
&config,
&lock,
Some(CONTROLLER),
BridgeRequest::ListOperators,
)
.await;
assert_eq!(answered.status(), StatusCode::OK); assert_eq!(answered.status(), StatusCode::OK);
let body = axum::body::to_bytes(answered.into_body(), usize::MAX) let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
.await .await

View file

@ -11,9 +11,9 @@
//! connection attempt: no window in which this process holds a token //! connection attempt: no window in which this process holds a token
//! that outlives its intended use. //! that outlives its intended use.
//! //!
//! `None` when this deployment did not wire a bridge up — the bridge only //! `None` when this deployment set no bridge address; the default is the
//! exists on hosts that also run `swarm-authelia`, so a controller split //! `/bridge/` location on the authelia vhost, from every host. Without one,
//! from it can neither create an identity nor read the roster. //! this daemon can neither create an identity nor read the roster.
//! //!
//! Each caller answers that absence in its own terms rather than this //! Each caller answers that absence in its own terms rather than this
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails //! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails

View file

@ -741,8 +741,7 @@ struct AppState {
/// Env var the controller's NixOS module sets from /// Env var the controller's NixOS module sets from
/// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory /// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory
/// (this daemon has no "self" to exclude) rather than peers-minus-self /// (this daemon has no "self" to exclude) rather than peers-minus-self.
/// (`services.hyperhive.swarm.peerHives`, which other consumers use).
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES"; const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or /// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or