deploy: split the homeserver's host decisions out of swarm.matrix

`swarm.*` is what a hive needs to be a *client* of the swarm. For the
homeserver that is what it IS from anywhere: its package, the name it
answers to, the ports and URLs it is reached on, and the client id it is
registered under. Whether it is exposed, which peers it trusts, how large
a request it accepts and where its host-local secrets sit are decisions
of the machine running it, so openFirewall, trustedServers,
maxRequestSize, registrationTokenFile, gui.enable and
sso.clientSecretFile move to `deploy.matrix.*`.

Two sub-blocks split rather than moving whole, on their own evidence.
`gui.enable` is whether THIS host serves the web client; `gui.package` is
which client, an artifact identity, and stays. `sso.clientSecretFile` is a
path on one host; `clientId` must match the id in authelia's register, so
it is swarm-wide. Each half now points at the other, because the rendered
docs put them on separate pages.

hive-gateway passed the whole `swarm.matrix` attrset into vhosts.nix, so
that file read a moving option through an argument with no option path
anywhere in it. It now takes `matrixDeployCfg` beside `matrixCfg` — the
only shape that carries a split namespace across that boundary.

While there: vhosts.nix read `matrixCfg.enable`, which has been a rename
alias for `deploy.matrix.enable` since the enable moved. Reading it made
the module system print `Obsolete option services.hyperhive.swarm.matrix.
enable is used` on EVERY evaluation of every host — a deprecation warning
no operator could silence, because the config tripping it was ours. That
shim lives in hive-matrix.nix rather than in this file's table, which is
why deploy.nix's header claim to be their single home is now qualified
in the new block's comment.

glue-matrix-bao-token.nix read the registration token through its own
`matrixCfg` alias; with that read repointed, the binding had no reader
left, so it goes, and the comment naming it is reworded.

module-eval gains a case configuring a hive through all six OLD paths and
asserting two rendered effects — the host firewall's port list and the
container's bind-mount table — because the new paths evaluate fine
without the shims. `gui.enable` is set to the opposite of its default so
the definition has to land rather than agreeing with it by accident.
This commit is contained in:
atlas 2026-09-07 11:23:47 +02:00 committed by mara
commit 7003d14d2c
11 changed files with 214 additions and 133 deletions

View file

@ -178,7 +178,7 @@ in
Removed rather than defaulted to true so a config that turned it
OFF fails here, where the line is, instead of silently gaining a
login flow on the next rebuild. Drop the line; if it was false,
set services.hyperhive.swarm.matrix.sso.clientSecretFile and
set services.hyperhive.deploy.matrix.sso.clientSecretFile and
services.hyperhive.swarm.authelia.url as the assertions describe.
'')
];
@ -312,6 +312,91 @@ in
'';
};
allowEncryption = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Server-side switch for matrix end-to-end encryption sets
tuwunel's `allow_encryption`. Off by default: on the hive-internal
homeserver the operator already controls the transport, so server
E2EE adds key-management overhead (cross-signing, device
verification, undecryptable-message recovery) without a clear
threat-model win for the common single-hive case. Turn on when
agents join encrypted rooms on external / federated homeservers,
or when the operator wants message contents opaque to the
homeserver admin. Independent of the agent matrix client, which
always supports decryption so it can read encrypted rooms it is
invited to regardless of this flag; this option only governs
whether THIS homeserver permits room encryption.
'';
};
gui = {
# Whether this host serves it is `deploy.matrix.gui.enable`.
package = lib.mkOption {
type = lib.types.package;
default = fluffychat-web-fixed;
defaultText = lib.literalMD ''
`pkgs.fluffychat-web` with a `postInstall` patch that adds
the three files `flutter341.buildFlutterApplication` skips.
'';
description = ''
Static web client dist served at `matrix.<hive>/`. Override
to swap fluffychat for hydrogen-web, cinny, element-web, or
an out-of-tree dist any replacement is mounted at the
sub-domain root with the upstream-default `<base href "/">`,
no sub-path gymnastics needed.
'';
};
};
# This homeserver always delegates login to the swarm's authelia, as
# an OIDC relying party — matrix SSO (`m.login.sso`), offered
# alongside password login. No toggle: a homeserver in a swarm is a
# client of that swarm's identity provider.
#
# ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which point
# the other way: those make this homeserver an *authorization server*
# for matrix clients. This family makes it a *client* of an external
# identity provider. The two share the protocol's name and answer
# opposite questions.
#
# This **adds** a way in. Password login keeps working: an identity
# provider that can take the homeserver offline when it hiccups is a
# worse homeserver than one with two ways in — which is also what
# makes always-on safe. Making authelia the *only* path is a
# separate, reversible switch (tuwunel's `login_with_password`),
# deliberately not folded in here.
#
# ⚠️ Matrix SSO lives **inside** the homeserver, never behind a
# forward-auth proxy: the client-server API is spoken by non-browser
# clients holding matrix access tokens — every agent's own
# `hive-matrix-daemon` — plus federation, and a proxy in front of
# `/_matrix/` breaks all of it.
sso = {
clientId = lib.mkOption {
type = lib.types.str;
default = "tuwunel";
description = ''
OAuth2 client id this homeserver identifies itself with. Must
match the `id` of the corresponding entry in
`services.hyperhive.swarm.authelia.oidc.clients`.
The secret it pairs with is a host path, so it lives at
`services.hyperhive.deploy.matrix.sso.clientSecretFile`.
'';
};
};
};
# What stays above is what the homeserver IS from any hive's point of view:
# its package, the name it answers to, the ports and URLs it is reached on,
# and the client id it is registered under. What lives here is what the host
# running it decides — whether it is exposed, which peers it trusts, how large
# a request it accepts, and where its host-local secrets sit. Same rule as
# ./swarm-victorialogs.nix; `enable` already lives in ./deploy.nix, which also
# carries the renames.
options.services.hyperhive.deploy.matrix = {
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
@ -327,7 +412,7 @@ in
**Breaking change**: this used to default to `true`. If you
relied on the old default for external reach, add
`services.hyperhive.swarm.matrix.openFirewall = true;` to your host
`services.hyperhive.deploy.matrix.openFirewall = true;` to your host
config before rebuilding.
Note: federation (the matrix-spec well-known port 8448) is
@ -381,112 +466,43 @@ in
'';
};
allowEncryption = lib.mkOption {
gui.enable = lib.mkOption {
type = lib.types.bool;
default = false;
default = deployCfg.matrix.enable;
defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable";
description = ''
Server-side switch for matrix end-to-end encryption sets
tuwunel's `allow_encryption`. Off by default: on the hive-internal
homeserver the operator already controls the transport, so server
E2EE adds key-management overhead (cross-signing, device
verification, undecryptable-message recovery) without a clear
threat-model win for the common single-hive case. Turn on when
agents join encrypted rooms on external / federated homeservers,
or when the operator wants message contents opaque to the
homeserver admin. Independent of the agent matrix client, which
always supports decryption so it can read encrypted rooms it is
invited to regardless of this flag; this option only governs
whether THIS homeserver permits room encryption.
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
Requires `swarm.matrix.gatewayHost != null` (default `matrix.<hive>`
when hive-domain set); the gateway itself always runs. When
off, the dashboard's `M4TR1X ` tab is hidden. See
`docs/networking/gateway.md` for the discovery flow that lets clients
auto-find the sub-domain. The client build itself is
`swarm.matrix.gui.package` which client, as opposed to whether
this host serves it.
'';
};
gui = {
enable = lib.mkOption {
type = lib.types.bool;
default = deployCfg.matrix.enable;
defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable";
description = ''
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
Requires `matrix.gatewayHost != null` (default `matrix.<hive>`
when hive-domain set); the gateway itself always runs. When
off, the dashboard's `M4TR1X ` tab is hidden. See
`docs/networking/gateway.md` for the discovery flow that lets clients
auto-find the sub-domain.
'';
};
sso.clientSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/tuwunel-oidc/tuwunel.secret";
description = ''
Path **inside the matrix container** holding the client
secret's plaintext.
package = lib.mkOption {
type = lib.types.package;
default = fluffychat-web-fixed;
defaultText = lib.literalMD ''
`pkgs.fluffychat-web` with a `postInstall` patch that adds
the three files `flutter341.buildFlutterApplication` skips.
'';
description = ''
Static web client dist served at `matrix.<hive>/`. Override
to swap fluffychat for hydrogen-web, cinny, element-web, or
an out-of-tree dist any replacement is mounted at the
sub-domain root with the upstream-default `<base href "/">`,
no sub-path gymnastics needed.
'';
};
};
A path, never a value: an OIDC client secret has two holders
in two containers (authelia keeps a hash, this homeserver
needs the plaintext), and a literal written here would be
rendered into the world-readable nix store.
# This homeserver always delegates login to the swarm's authelia, as
# an OIDC relying party — matrix SSO (`m.login.sso`), offered
# alongside password login. No toggle: a homeserver in a swarm is a
# client of that swarm's identity provider.
#
# ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which point
# the other way: those make this homeserver an *authorization server*
# for matrix clients. This family makes it a *client* of an external
# identity provider. The two share the protocol's name and answer
# opposite questions.
#
# This **adds** a way in. Password login keeps working: an identity
# provider that can take the homeserver offline when it hiccups is a
# worse homeserver than one with two ways in — which is also what
# makes always-on safe. Making authelia the *only* path is a
# separate, reversible switch (tuwunel's `login_with_password`),
# deliberately not folded in here.
#
# ⚠️ Matrix SSO lives **inside** the homeserver, never behind a
# forward-auth proxy: the client-server API is spoken by non-browser
# clients holding matrix access tokens — every agent's own
# `hive-matrix-daemon` — plus federation, and a proxy in front of
# `/_matrix/` breaks all of it.
sso = {
clientId = lib.mkOption {
type = lib.types.str;
default = "tuwunel";
description = ''
OAuth2 client id this homeserver identifies itself with. Must
match the `id` of the corresponding entry in
`services.hyperhive.swarm.authelia.oidc.clients`.
'';
};
clientSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/tuwunel-oidc/tuwunel.secret";
description = ''
Path **inside the matrix container** holding the client
secret's plaintext.
A path, never a value: an OIDC client secret has two holders
in two containers (authelia keeps a hash, this homeserver
needs the plaintext), and a literal written here would be
rendered into the world-readable nix store.
Required when `enable` is set deliberately no fallback. A
homeserver that boots with SSO half-configured is worse than
one that fails to evaluate: tuwunel reads OIDC from its
config file rather than a database row, so a malformed block
can stop the server outright instead of merely hiding a
button.
'';
};
Deliberately no fallback a homeserver that boots with SSO
half-configured is worse than one that fails to evaluate:
tuwunel reads OIDC from its config file rather than a database
row, so a malformed block can stop the server outright instead
of merely hiding a button. The id this pairs with is
`swarm.matrix.sso.clientId`, which is swarm-wide because it must
match authelia's register.
'';
};
};
@ -520,7 +536,7 @@ in
# page). See `services.hyperhive.swarm.controller.links`'s
# description.
services.hyperhive.swarm.controller.links =
lib.optional (cfg.gatewayHost != null && cfg.gui.enable)
lib.optional (cfg.gatewayHost != null && deployCfg.matrix.gui.enable)
{
label = "Matrix";
icon = "💬";
@ -534,7 +550,7 @@ in
# contributes instead of replacing it.
#
# The dashboard needs no equivalent — it routes by path.
services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable ''
services.nginx.appendHttpConfig = lib.optionalString deployCfg.matrix.gui.enable ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";
@ -569,7 +585,7 @@ in
'';
};
}
// lib.optionalAttrs cfg.gui.enable {
// lib.optionalAttrs deployCfg.matrix.gui.enable {
# fluffychat at sub-domain root, SPA-fallback via the
# Accept-header `$matrix_spa_target` map above.
"/" = {
@ -588,7 +604,7 @@ in
'';
};
}
// lib.optionalAttrs (!cfg.gui.enable) {
// lib.optionalAttrs (!deployCfg.matrix.gui.enable) {
"/" = {
return = "404";
};
@ -618,10 +634,10 @@ in
# Fail at EVAL, not at boot. tuwunel reads its identity providers
# from the config file, so a half-configured one does not hide a
# login button — it can stop the homeserver from starting at all.
assertion = cfg.sso.clientSecretFile != null;
assertion = deployCfg.matrix.sso.clientSecretFile != null;
message = ''
This homeserver's SSO login flow requires
services.hyperhive.swarm.matrix.sso.clientSecretFile the path
services.hyperhive.deploy.matrix.sso.clientSecretFile the path
(inside the matrix container) holding the OIDC client secret's
plaintext.
@ -683,7 +699,7 @@ in
# Same case, same reasoning: this host minted the secret, so it can say
# where the homeserver will find it.
services.hyperhive.swarm.matrix.sso.clientSecretFile = lib.mkIf ssoLocal (
services.hyperhive.deploy.matrix.sso.clientSecretFile = lib.mkIf ssoLocal (
lib.mkDefault matrixSecretPath
);
@ -729,7 +745,7 @@ in
set -euo pipefail
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString cfg.sso.clientSecretFile}"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"}
# authelia's container is up, but its first-boot generator may
# still be minting. Bounded wait, then fail: a silent skip here
@ -811,7 +827,7 @@ in
);
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
tokenFile=${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
if [ ! -s "$tokenFile" ]; then
mkdir -p "$(dirname "$tokenFile")"
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
@ -833,8 +849,8 @@ in
# Read-only bind of the host-managed registration token; tuwunel
# reads it via systemd LoadCredential below (not directly).
bindMounts = {
${cfg.registrationTokenFile} = {
hostPath = cfg.registrationTokenFile;
${deployCfg.matrix.registrationTokenFile} = {
hostPath = deployCfg.matrix.registrationTokenFile;
isReadOnly = true;
};
}
@ -923,11 +939,11 @@ in
# `address` + `port` are upstream `listOf` — wrap singles.
address = [ "0.0.0.0" ];
port = [ cfg.httpPort ];
max_request_size = cfg.maxRequestSize;
max_request_size = deployCfg.matrix.maxRequestSize;
# Federation enabled at the protocol level; empty
# trustedServers keeps it effectively closed.
allow_federation = true;
trusted_servers = cfg.trustedServers;
trusted_servers = deployCfg.matrix.trustedServers;
# Token-gated registration. The absent
# `yes_i_am_very_very_sure_…_open_registration_…` flag
# keeps the server closed to anyone without the token.
@ -995,18 +1011,18 @@ in
# host-side chown :tuwunel / GID-pin gymnastics needed.
# See `man systemd.exec` → LoadCredential.
systemd.services.tuwunel.serviceConfig.LoadCredential = [
"registration_token:${toString cfg.registrationTokenFile}"
"registration_token:${toString deployCfg.matrix.registrationTokenFile}"
# Same mechanism, second secret. tuwunel re-reads this file on
# every OAuth exchange, not just at startup, so it has to
# outlive the unit's start — a credentials path does.
"oidc_client_secret:${toString cfg.sso.clientSecretFile}"
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
];
environment.systemPackages = [ cfg.package ];
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
networking.firewall = lib.mkIf deployCfg.matrix.openFirewall {
allowedTCPPorts = [
cfg.httpPort
];