Three host units poll up to 120s for a secret authelia mints on its first boot, and all three are `Type=oneshot` with no `TimeoutStartSec`. systemd's `DefaultTimeoutStartSec` is 90s, so it kills them at 90 — before the script reaches its own `exit 1` and names the file that never appeared. The wait itself is fine; what's lost is the diagnosis. On a fresh hive the operator gets a bare start-timeout instead of "authelia has not minted <path>", several layers from the container that was actually slow. Found while writing the same unit for Grafana, where the timeout is set — so this is the existing three catching up with it, not a new pattern.
1000 lines
46 KiB
Nix
1000 lines
46 KiB
Nix
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.matrix;
|
||
networkCfg = config.services.hyperhive.network;
|
||
tlsCfg = config.services.hyperhive.tls;
|
||
gatewayCfg = config.services.hyperhive.gateway;
|
||
hyperhiveDomain = config.services.hyperhive.domain;
|
||
|
||
# Same runtime→build-time bridge hive-ci and hive-forge already cross:
|
||
# binds the hive trust bundle (which folds in the swarm root) into the
|
||
# container and orders the container after `hive-tls-ca.service`. The
|
||
# *consumption* is per-runtime and stays here — see the bundle service
|
||
# in the container config below.
|
||
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||
useSelfSigned = caTrust.useSelfSigned;
|
||
# tuwunel's own combined bundle, assembled at start. /run is tmpfs, so
|
||
# it is rebuilt from the current CA every boot rather than going stale.
|
||
matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt";
|
||
swarmDomain = config.services.hyperhive.swarm.domain;
|
||
|
||
# `url` is the half of the authelia module that exists on EVERY hive —
|
||
# null when no SSO provider is configured anywhere, which the assertion
|
||
# below turns into an eval failure rather than a discovery request to
|
||
# `null/.well-known/…`.
|
||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||
autheliaUrl = autheliaCfg.url;
|
||
|
||
# The all-local case: this host runs BOTH the homeserver and the swarm's
|
||
# authelia, so the secret can be moved without an operator. The other
|
||
# two cases (swarm side, remote hive) leave `clientSecretFile` to be set
|
||
# explicitly — same split the forge module documents.
|
||
ssoLocal = cfg.sso.enable && autheliaCfg.enable;
|
||
|
||
# Where the plaintext lands inside the matrix container. Under /var/lib
|
||
# rather than /run: the homeserver may start before the delivery unit on
|
||
# a later boot, and a secret that evaporates on reboot turns a working
|
||
# login into an intermittent one.
|
||
matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret";
|
||
|
||
# ⚠️ tuwunel does NOT read the path above directly, and this indirection
|
||
# is not ceremony. Upstream's own words: "under systemd the path must be
|
||
# visible to the service after sandboxing (ReadWritePaths / ProtectHome),
|
||
# typically by placing the file under /etc/tuwunel/" — which this
|
||
# container has no writable etc for. `LoadCredential` is the answer
|
||
# already in use two units below for the registration token, and for the
|
||
# same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact
|
||
# with no host-side chown or GID pinning.
|
||
matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret";
|
||
|
||
# Format-locked by tuwunel, not chosen here: the callback host must point
|
||
# directly at the matrix server and the path is fixed at
|
||
# `/_matrix/client/unstable/login/sso/callback/<client_id>`. Built once
|
||
# and read twice — by the homeserver's own config and by the client entry
|
||
# handed to authelia — because a redirect-URI mismatch is a rejected
|
||
# login with no error text worth reading.
|
||
ssoCallbackUrl = "https://${toString cfg.gatewayHost}/_matrix/client/unstable/login/sso/callback/${cfg.sso.clientId}";
|
||
# Falls back to the SWARM domain: a swarm runs one homeserver, so its
|
||
# identifier belongs to the swarm rather than to whichever hive happens
|
||
# to host it — otherwise moving the container between hives would look
|
||
# like a different homeserver.
|
||
#
|
||
# ⚠️ Changing this default is a BREAKING change in a way that moving
|
||
# `gatewayHost` was not: `serverName` is baked irrevocably into every
|
||
# user and room id, so a deployment that rebuilds onto a new one is a
|
||
# *different homeserver*, not a renamed one. Existing hives pin the old
|
||
# value explicitly (see the option's description); the default is what
|
||
# a fresh swarm gets.
|
||
#
|
||
# Total on a null swarm domain, deliberately: the required-domain
|
||
# assertion in hive-network.nix is what should fire, not a coercion
|
||
# error from an unrelated option interpolating null.
|
||
effectiveServerName =
|
||
if cfg.serverName != null then
|
||
cfg.serverName
|
||
else if swarmDomain != null then
|
||
swarmDomain
|
||
else
|
||
"invalid";
|
||
|
||
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
|
||
# skips the dart web-worker compile + the emscripten native_imaging
|
||
# build. Two derivations below cover both. Full rationale (why
|
||
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
|
||
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
|
||
# fluffychat-web build fixes.
|
||
|
||
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
|
||
pname = "fluffychat-web-imaging";
|
||
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
|
||
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
|
||
|
||
nativeBuildInputs = with pkgs; [
|
||
emscripten
|
||
cmake
|
||
gnumake
|
||
jq
|
||
];
|
||
|
||
# cmake runs inside js/Makefile via `emcmake cmake`; the default
|
||
# configurePhase would invoke cmake at the package root (no
|
||
# CMakeLists) and fail.
|
||
dontConfigure = true;
|
||
|
||
buildPhase = ''
|
||
runHook preBuild
|
||
# emscripten on-demand sysroot build needs writable HOME + cache.
|
||
export HOME=$TMPDIR
|
||
export EM_CACHE=$TMPDIR/.emscriptencache
|
||
mkdir -p $EM_CACHE
|
||
# `make -C js` keeps pwd at source root for the installPhase.
|
||
make -C js Imaging.js Imaging.wasm
|
||
runHook postBuild
|
||
'';
|
||
|
||
installPhase = ''
|
||
runHook preInstall
|
||
mkdir -p $out
|
||
install -m 644 js/Imaging.js $out/Imaging.js
|
||
install -m 644 js/Imaging.wasm $out/Imaging.wasm
|
||
runHook postInstall
|
||
'';
|
||
|
||
meta = with pkgs.lib; {
|
||
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web";
|
||
homepage = "https://pub.dev/packages/native_imaging";
|
||
license = licenses.agpl3Plus;
|
||
};
|
||
};
|
||
|
||
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
|
||
# dart from the flutter341 closure (already pulled, no incremental
|
||
# cost) to compile the web-worker entry point.
|
||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
|
||
|
||
postInstall = (old.postInstall or "") + ''
|
||
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
|
||
# package_config walk-up hits buildFlutterApplication's
|
||
# pub-get output `.dart_tool/`.
|
||
${pkgs.flutter341.dart}/bin/dart compile js \
|
||
-o $out/native_executor.js \
|
||
web/native_executor.dart
|
||
|
||
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
|
||
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
||
'';
|
||
});
|
||
in
|
||
{
|
||
# Private matrix-tuwunel homeserver wrapped in a nixos-container,
|
||
# optional fluffychat-web client at matrix.<hive>/. Container shape,
|
||
# serverName vs gatewayHost split, provisioning flow (registration
|
||
# token + LoadCredential), assertion rationale, initial rollout
|
||
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
|
||
# knobs: docs/gateway.md.
|
||
|
||
# Matrix moved under `swarm` when the swarm-global services were
|
||
# consolidated. One rename for the namespace: the subtree comes with it,
|
||
# so existing hives keep evaluating and get one warning naming both paths.
|
||
imports = [
|
||
(lib.mkRenamedOptionModule
|
||
[ "services" "hyperhive" "matrix" ]
|
||
[ "services" "hyperhive" "swarm" "matrix" ]
|
||
)
|
||
];
|
||
|
||
options.services.hyperhive.swarm.matrix = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
description = ''
|
||
Run hive-matrix — a private matrix-tuwunel homeserver (in a
|
||
nixos-container) for hyperhive agents.
|
||
|
||
Matrix is a swarm-wide service — one homeserver, not one per
|
||
hive — so `services.hyperhive.swarm.enableRequiredServices`
|
||
turns this on as part of saying the swarm's shared services live
|
||
on this host. Set it here directly to run the homeserver
|
||
somewhere other than the host that holds the rest of them.
|
||
'';
|
||
};
|
||
|
||
package = lib.mkOption {
|
||
type = lib.types.package;
|
||
default = pkgs.matrix-tuwunel;
|
||
defaultText = lib.literalExpression "pkgs.matrix-tuwunel";
|
||
description = ''
|
||
matrix-tuwunel package to run inside the container. Defaults
|
||
to nixpkgs's `pkgs.matrix-tuwunel`. Override to pin a
|
||
specific upstream if you need an unreleased feature.
|
||
'';
|
||
};
|
||
|
||
serverName = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "chat.example.org";
|
||
description = ''
|
||
Matrix `server_name` — the host part of every user ID
|
||
(`@argus:<server_name>`) and room ID minted on this
|
||
homeserver. CRITICAL: must be stable from day one because
|
||
it's embedded irrevocably in the identifiers.
|
||
|
||
Defaults to `services.hyperhive.swarm.domain` (the bare swarm
|
||
domain), because **a swarm runs one homeserver** — tying its
|
||
identity to a single hive's domain would make relocating the
|
||
container between hives look like a different homeserver.
|
||
Combined with the `.well-known/matrix/{client,server}` routes
|
||
the gateway serves at that domain, clients auto-discover the
|
||
actual matrix endpoint without needing a subdomain. Override
|
||
here only if you need a different server_name shape (e.g.
|
||
`chat.example.org` for a bespoke hostname).
|
||
|
||
**Breaking change, and the one on this page that cannot be
|
||
undone by rebuilding.** This default has now moved twice — from
|
||
`matrix.''${services.hyperhive.domain}`, then to the bare hive
|
||
domain, and now to the swarm domain. Every existing homeserver
|
||
must pin whichever value it already minted ids under, e.g.
|
||
|
||
```nix
|
||
services.hyperhive.swarm.matrix.serverName =
|
||
config.services.hyperhive.domain; # or "matrix.''${…domain}"
|
||
```
|
||
|
||
before rebuilding. Adopting a new `server_name` does not rename
|
||
the old users and rooms — it strands them, because their ids
|
||
still name a homeserver that no longer answers.
|
||
'';
|
||
};
|
||
|
||
httpPort = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 8008;
|
||
description = ''
|
||
TCP port tuwunel serves the matrix client-server API on.
|
||
Default 8008 is the matrix-spec well-known port. Sits
|
||
outside hyperhive's claimed ranges (dashboard 7000, every
|
||
agent in 8100..8999 via FNV-1a hash). Federation listens on
|
||
`federationPort` separately.
|
||
'';
|
||
};
|
||
|
||
apiUrl = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = if cfg.enable then "http://127.0.0.1:${toString cfg.httpPort}" else null;
|
||
defaultText = lib.literalExpression ''
|
||
if services.hyperhive.swarm.matrix.enable
|
||
then "http://127.0.0.1:''${toString services.hyperhive.swarm.matrix.httpPort}"
|
||
else null
|
||
'';
|
||
example = "https://matrix.example.com";
|
||
description = ''
|
||
Client-server API base URL **hive-c0re itself** uses to
|
||
provision matrix (register agent users, create the hive space
|
||
and chat room, invite members). Distinct from the agent-facing
|
||
`hyperhive.matrix.url`, which is the gateway vhost handed to
|
||
each agent's `hive-matrix-daemon`.
|
||
|
||
Defaults to the loopback listener **only when this module is the
|
||
thing running tuwunel** — in that case the address is not a
|
||
guess, it is where this module just put the container. Set it
|
||
explicitly (with `enable = false`) when the homeserver runs on
|
||
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
|
||
and matrix provisioning no-ops. There is deliberately no
|
||
fallback compiled into the daemon: an address baked into the
|
||
binary is one that builds fine and then talks to the wrong
|
||
machine.
|
||
'';
|
||
};
|
||
|
||
gatewayHost = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
# `chat.` under the SWARM domain — both halves change: a swarm runs
|
||
# one homeserver, and the label follows the service rather than the
|
||
# protocol.
|
||
#
|
||
# Total on a null swarm domain so the required-domain assertion in
|
||
# hive-network.nix is the thing that fires; see the comment there.
|
||
default = if swarmDomain == null then "chat.invalid" else "chat.${swarmDomain}";
|
||
defaultText = lib.literalExpression ''"chat.''${services.hyperhive.swarm.domain}"'';
|
||
example = "matrix.example.com";
|
||
description = ''
|
||
Public hostname for the matrix homeserver behind the gateway.
|
||
Defaults to `chat.''${services.hyperhive.swarm.domain}` — the
|
||
swarm's domain, because a swarm runs **one** homeserver. Set to
|
||
`null` to skip the gateway vhost (tuwunel stays direct on
|
||
`httpPort`). See `docs/gateway.md` for the vhost map + matrix
|
||
discovery flow, and the federation port-8448 caveat at the
|
||
bottom of that doc.
|
||
|
||
⚠️ **`gatewayHost` and `serverName` are different things, and
|
||
they carry very different costs.** `gatewayHost` is the API
|
||
listener hostname (where nginx proxies `/_matrix/*`) and is
|
||
free to change: it is a routing detail clients rediscover
|
||
through `.well-known`. `serverName` is the matrix-identifier
|
||
domain embedded **irrevocably** in every user and room id —
|
||
adopting a new one is a different homeserver, not a rename.
|
||
Both defaults now sit under the swarm domain, but only this
|
||
one is safe to move on a running deployment.
|
||
|
||
A deployment that was running before this moved keeps its
|
||
current name by pinning
|
||
`matrix.''${services.hyperhive.domain}` here — exactly what the
|
||
old default rendered.
|
||
'';
|
||
};
|
||
|
||
openFirewall = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
example = true;
|
||
description = ''
|
||
Open `httpPort` in the host firewall. Off by default
|
||
(secure-by-default): the host reaches the homeserver on
|
||
loopback, and agent containers reach it at `matrix.<domain>`
|
||
via the gateway — so the firewall open only matters for
|
||
access from outside the host. Flip to `true` when announcing
|
||
the homeserver to other hives or when an external matrix
|
||
client needs to reach the client-server API directly.
|
||
|
||
**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
|
||
config before rebuilding.
|
||
|
||
Note: federation (the matrix-spec well-known port 8448) is
|
||
intentionally not opened here. tuwunel serves the federation
|
||
API on the same `httpPort` as the client-server API by
|
||
default; reaching it on 8448 requires either binding tuwunel
|
||
to that port explicitly OR a reverse-proxy + `.well-known/
|
||
matrix/server` delegation, neither of which lives in this
|
||
module. Add that proxy config alongside whatever serves your
|
||
dashboard or forge on 443.
|
||
'';
|
||
};
|
||
|
||
trustedServers = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "matrix.org" ];
|
||
description = ''
|
||
List of trusted matrix servers (homeservers whose signing
|
||
keys this server will fetch identity-server-style). Empty
|
||
by default — federation is enabled at the protocol level
|
||
but no peer is trusted until listed here, so the homeserver
|
||
is effectively closed until the operator declares hive
|
||
peers explicitly.
|
||
'';
|
||
};
|
||
|
||
maxRequestSize = lib.mkOption {
|
||
type = lib.types.ints.positive;
|
||
default = 20000000;
|
||
description = ''
|
||
Maximum size in bytes of a single matrix client request body.
|
||
Default 20 MB matches the matrix-spec recommendation for
|
||
media uploads + the upstream tuwunel default.
|
||
'';
|
||
};
|
||
|
||
registrationTokenFile = lib.mkOption {
|
||
type = lib.types.path;
|
||
default = "/var/lib/hyperhive/matrix-register-token";
|
||
description = ''
|
||
Host path to a file containing the matrix registration token
|
||
tuwunel reads to authorise new-account creation. The token is
|
||
generated automatically by `hive-c0re` on first boot (32-byte
|
||
random hex, mode 0600) and is bind-mounted read-only into the
|
||
tuwunel container at the same path. Agents never see this
|
||
token — hive-c0re uses it to provision per-agent accounts
|
||
and the agent only receives the resulting `access_token`.
|
||
Override only when integrating with externally-managed
|
||
registration tokens.
|
||
'';
|
||
};
|
||
|
||
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 = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = cfg.enable;
|
||
defaultText = lib.literalExpression "config.services.hyperhive.swarm.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/gateway.md` for the discovery flow that lets clients
|
||
auto-find the sub-domain.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
};
|
||
|
||
sso = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
description = ''
|
||
Let this homeserver delegate login to the swarm's authelia,
|
||
as an OIDC relying party — matrix SSO (`m.login.sso`), an
|
||
extra flow offered alongside password login.
|
||
|
||
⚠️ 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 option makes
|
||
it a *client* of an external identity provider. The two
|
||
families 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.
|
||
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.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf cfg.enable {
|
||
# Matrix's own gateway surface: the sub-domain vhost, the name the
|
||
# hive resolver answers for, and the Accept-header map that vhost's
|
||
# SPA fallback reads. All three are matrix knowledge and none of
|
||
# them is the gateway's business.
|
||
#
|
||
# `gatewayHost = null` means matrix is reachable directly rather
|
||
# than fronted, so there is no name to claim and no vhost to serve —
|
||
# every clause below carries that guard.
|
||
services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost;
|
||
|
||
# This swarm-ui quick-links entry. Gated on `gui.enable` too, not just
|
||
# `gatewayHost != null`: `/` on that vhost only serves fluffychat
|
||
# (below) when the GUI is on — otherwise the link would 404, the same
|
||
# reason the old dashboard's H0M3 page hides its Matrix tile on
|
||
# `state.matrix_gui_enabled` rather than `gatewayHost` alone. See
|
||
# `services.hyperhive.swarm.controller.links`'s description.
|
||
services.hyperhive.swarm.controller.links =
|
||
lib.optional (cfg.gatewayHost != null && cfg.gui.enable)
|
||
{
|
||
label = "Matrix";
|
||
icon = "💬";
|
||
url = "https://${cfg.gatewayHost}/";
|
||
};
|
||
|
||
# Accept-header SPA map, used only by the `/` location below (see
|
||
# docs/gateway.md "SPA fallback"): text/html → index.html, else a
|
||
# sentinel so `try_files` falls through to 404. `appendHttpConfig`
|
||
# is a `lines` option, so this merges with anything else the host
|
||
# contributes instead of replacing it.
|
||
#
|
||
# The dashboard needs no equivalent — it routes by path.
|
||
services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable ''
|
||
map $http_accept $matrix_spa_target {
|
||
default "/__matrix_spa_no_html_fallback";
|
||
"~*text/html" "/index.html";
|
||
}
|
||
'';
|
||
|
||
# `server_name = gatewayHost`. `/_matrix/*` → tuwunel (CORS `*`, 50M
|
||
# body cap, 1h long-poll timeout). `/` serves fluffychat, or 404
|
||
# with the GUI off. nginx's longest-prefix rule puts `/_matrix/`
|
||
# ahead of `/` with no ordering needed.
|
||
#
|
||
# ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here.
|
||
# It stays on the hive's own vhost because the spec requires it to
|
||
# be served at the *server name*, which is the hive domain — it is
|
||
# the hive answering "where is my homeserver", not the homeserver
|
||
# answering for itself.
|
||
services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) {
|
||
"${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // {
|
||
listen = gatewayCfg.lib.listen;
|
||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||
locations = {
|
||
"/_matrix/" = {
|
||
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}";
|
||
proxyWebsockets = true;
|
||
extraConfig = ''
|
||
proxy_buffering off;
|
||
client_max_body_size 50M;
|
||
proxy_read_timeout 1h;
|
||
proxy_send_timeout 1h;
|
||
${gatewayCfg.lib.securityHeaders}
|
||
add_header Access-Control-Allow-Origin *;
|
||
'';
|
||
};
|
||
}
|
||
// lib.optionalAttrs cfg.gui.enable {
|
||
# fluffychat at sub-domain root, SPA-fallback via the
|
||
# Accept-header `$matrix_spa_target` map above.
|
||
"/" = {
|
||
alias = "${cfg.gui.package}/";
|
||
extraConfig = ''
|
||
try_files $uri $uri/ $matrix_spa_target =404;
|
||
'';
|
||
};
|
||
# FluffyChat boot-config pre-fill so the client's
|
||
# `.well-known/matrix/client` lookup hits the right delegation
|
||
# endpoint. `domain` is required, so this is always present.
|
||
"= /config.json" = {
|
||
extraConfig = ''
|
||
default_type application/json;
|
||
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
||
'';
|
||
};
|
||
}
|
||
// lib.optionalAttrs (!cfg.gui.enable) {
|
||
"/" = {
|
||
return = "404";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
# `serverName` is irrevocably embedded in user/room IDs; it derives
|
||
# from `services.hyperhive.domain` (required, asserted in
|
||
# hive-network.nix) when not set explicitly, so no separate
|
||
# domain/serverName assertion is needed here. gatewayHost may not be
|
||
# "" (same footgun as forge.domain — nginx rejects an empty
|
||
# server_name). docs/matrix.md::Assertion rationale.
|
||
assertions = [
|
||
{
|
||
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
|
||
message = ''
|
||
services.hyperhive.swarm.matrix.gatewayHost = "" is rejected. The
|
||
rendered URLs would be invalid (nginx wildcard catch-all for
|
||
an empty server_name, /etc/hosts rejects empty entries).
|
||
Use `null` to disable the gateway vhost entirely (tuwunel
|
||
stays direct on httpPort), or set a non-empty hostname like
|
||
"matrix.example.com" or "homeserver.internal".
|
||
'';
|
||
}
|
||
{
|
||
# 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.enable || cfg.sso.clientSecretFile != null;
|
||
message = ''
|
||
services.hyperhive.swarm.matrix.sso.enable requires
|
||
sso.clientSecretFile — the path (inside the matrix container)
|
||
holding the OIDC client secret's plaintext.
|
||
|
||
On a hive that also runs the swarm's authelia this is wired up
|
||
for you. Set it explicitly when authelia lives on another
|
||
host: see docs/swarm/ for which secret goes where.
|
||
'';
|
||
}
|
||
{
|
||
# Without a provider URL there is nothing to discover against, and
|
||
# the rendered config would name `null` as its issuer.
|
||
assertion = !cfg.sso.enable || autheliaUrl != null;
|
||
message = ''
|
||
services.hyperhive.swarm.matrix.sso.enable requires
|
||
services.hyperhive.swarm.authelia.url — the base URL of the
|
||
swarm's SSO provider.
|
||
|
||
It defaults to this host's own instance only when this host
|
||
runs authelia. A hive that federates with a swarm sets it
|
||
explicitly to wherever that provider lives.
|
||
'';
|
||
}
|
||
{
|
||
# The callback URL must name the homeserver itself, and with no
|
||
# gateway vhost there is no public name for it to be built from.
|
||
assertion = !cfg.sso.enable || cfg.gatewayHost != null;
|
||
message = ''
|
||
services.hyperhive.swarm.matrix.sso.enable requires
|
||
services.hyperhive.swarm.matrix.gatewayHost.
|
||
|
||
tuwunel's SSO callback URL is format-locked to
|
||
`<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`,
|
||
and the identity provider redirects a browser to it — so it
|
||
has to be a name the browser can reach, which is exactly what
|
||
`gatewayHost` is. With it null the homeserver is direct on
|
||
httpPort and has no such name.
|
||
'';
|
||
}
|
||
];
|
||
|
||
# One declaration, two readers. The homeserver knows its own callback
|
||
# URL; making the operator restate it in authelia's client list would
|
||
# be a second source of truth for a string whose mismatch is a silent
|
||
# rejected login.
|
||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [
|
||
{
|
||
id = cfg.sso.clientId;
|
||
description = "HyperHive matrix";
|
||
redirectUris = [ ssoCallbackUrl ];
|
||
# tuwunel authenticates at the token endpoint by putting the
|
||
# secret in the POST body. Authelia enforces the *registered*
|
||
# method rather than accepting whichever one arrives, and its
|
||
# default is `client_secret_basic` — so without this the browser
|
||
# flow completes, consent is granted, and the very last hop fails
|
||
# with a 401 that names neither the secret nor the redirect.
|
||
tokenEndpointAuthMethod = "client_secret_post";
|
||
}
|
||
];
|
||
|
||
# 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 (
|
||
lib.mkDefault matrixSecretPath
|
||
);
|
||
|
||
# The delivery. It runs on the HOST because that is the only place both
|
||
# container trees are addressable: they share this host's network
|
||
# namespace, which makes them feel co-located, but their filesystem
|
||
# roots are separate — the homeserver cannot open a path inside
|
||
# authelia's tree however local the port looks.
|
||
#
|
||
# ⚠️ Deliberately a copy and not a `bindMounts` entry.
|
||
# nixos-container refuses to start when a bind source is missing, and
|
||
# this secret does not exist until authelia's first boot has minted it
|
||
# — so binding it would make the homeserver wait on a file that waits
|
||
# on a container that starts after it. On a fresh hive that is a
|
||
# permanent stall presenting as "matrix is broken", several layers from
|
||
# its cause.
|
||
#
|
||
# The registration token above dodges that with an activation script
|
||
# that pre-creates the file. ⚠️ That dodge is NOT available here:
|
||
# tuwunel requires the secret file to exist *and be non-empty*, so a
|
||
# zero-byte placeholder would satisfy the bind mount and then stop the
|
||
# homeserver from starting.
|
||
systemd.services.hive-matrix-oidc-secret = lib.mkIf ssoLocal {
|
||
description = "deliver the homeserver's OIDC client secret from authelia";
|
||
after = [ "container@${autheliaCfg.machine}.service" ];
|
||
requires = [ "container@${autheliaCfg.machine}.service" ];
|
||
before = [ "container@hive-matrix.service" ];
|
||
wantedBy = [ "container@hive-matrix.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
SyslogIdentifier = "hive-matrix-oidc-secret";
|
||
# Longer than the 120s bounded wait below, and that is the whole
|
||
# point: `DefaultTimeoutStartSec` is 90s, so without this systemd
|
||
# kills the unit at 90 — before it can emit the message naming the
|
||
# file it was waiting for. The failure then reads as a timeout with
|
||
# no cause rather than "authelia has not minted <path>", which is
|
||
# the one line that makes a fresh-hive SSO stall diagnosable.
|
||
TimeoutStartSec = "180s";
|
||
};
|
||
path = [ pkgs.coreutils ];
|
||
script = ''
|
||
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}"}
|
||
|
||
# authelia's container is up, but its first-boot generator may
|
||
# still be minting. Bounded wait, then fail: a silent skip here
|
||
# produces a homeserver whose SSO login dead-ends, which is the
|
||
# failure this whole design is trying not to ship.
|
||
for _ in $(seq 1 60); do
|
||
[ -s "$src" ] && break
|
||
sleep 2
|
||
done
|
||
if [ ! -s "$src" ]; then
|
||
echo "authelia has not minted $src after 120s" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# root-owned 0400, and deliberately NOT the forge's `stat -c %u`
|
||
# uid discovery: that reads the service's state dir to learn which
|
||
# uid to hand the file to, and tuwunel runs under `DynamicUser`, so
|
||
# there is no stable uid to discover. It never reads this path
|
||
# directly anyway — `LoadCredential` does, as root, before the
|
||
# sandbox and the dynamic user exist.
|
||
install -D -m 0400 -o root -g root "$src" "$dst"
|
||
'';
|
||
};
|
||
|
||
# ⚠️ Deliberately NO `networking.hosts` entry for authelia's name, and
|
||
# the difference from hive-forge (which needs one) is worth stating:
|
||
# that container resolves through the host's resolvers, where the swarm
|
||
# domain has no records. This one resolves through the hive's dnsmasq
|
||
# at `bridgeIp` (see the static resolv.conf below), and every
|
||
# `gateway.localNames` entry — authelia's domain among them — is
|
||
# already mapped there. Adding a loopback override would only create a
|
||
# second answer that can disagree with the first.
|
||
|
||
# Activation-time token generation — without this the bind-mount
|
||
# would hand tuwunel an empty file on first boot and break every
|
||
# registration until restart. Idempotent;
|
||
# docs/matrix.md::Provisioning flow.
|
||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
||
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
|
||
if [ ! -s "$tokenFile" ]; then
|
||
mkdir -p "$(dirname "$tokenFile")"
|
||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
|
||
echo >> "$tokenFile"
|
||
echo "hive-matrix: generated registration token at $tokenFile"
|
||
fi
|
||
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
||
chmod 0600 "$tokenFile"
|
||
'';
|
||
|
||
containers.hive-matrix = {
|
||
autoStart = true;
|
||
ephemeral = false;
|
||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||
privateNetwork = false;
|
||
# Read-only bind of the host-managed registration token; tuwunel
|
||
# reads it via systemd LoadCredential below (not directly).
|
||
bindMounts = {
|
||
${cfg.registrationTokenFile} = {
|
||
hostPath = cfg.registrationTokenFile;
|
||
isReadOnly = true;
|
||
};
|
||
}
|
||
// caTrust.bindMount;
|
||
config =
|
||
{ ... }:
|
||
{
|
||
system.stateVersion = "26.05";
|
||
|
||
# Shared host netns: this container's own firewall.service
|
||
# would rewrite the HOST ruleset (flush nixos-fw, drop the
|
||
# host's nixos-nat-* chains) at every boot — killing the
|
||
# bridge DHCP/DNS holes and agent NAT. The host firewall owns
|
||
# all filtering; never run one in here.
|
||
networking.firewall.enable = false;
|
||
|
||
# Swarm-internal trust reaches tuwunel at RUNTIME, not via
|
||
# `security.pki.certificateFiles`. That option is read when the
|
||
# system is BUILT, and the swarm root is deliberately a runtime
|
||
# file (`swarm.ca.stateDir`) because its key must never enter the
|
||
# store — so there is nothing build-time to name. The bind-mount
|
||
# above plus the bundle service below are what replaced it.
|
||
|
||
# tuwunel hard-fails to boot if `/etc/resolv.conf` has no
|
||
# `nameserver` line (`Failed to configure DNS resolver ... no
|
||
# nameservers found in config` → exit 1). This declarative
|
||
# nixos-container comes up with an EMPTY resolv.conf even with
|
||
# `networking.nameservers` set: the nixos-container default
|
||
# `useHostResolvConf = true` puts in-container resolvconf in
|
||
# host-tracking mode (ignores `networking.nameservers`, and never
|
||
# gets the host file across the shared-netns boundary), so it
|
||
# regenerates an empty file and tuwunel dies at boot.
|
||
#
|
||
# Trusting resolvconf to honour `networking.nameservers` doesn't
|
||
# work either — that's a RUNTIME resolvconf behaviour, not
|
||
# verifiable at eval time, and it still comes up empty in
|
||
# practice. So take resolvconf out of the loop entirely and
|
||
# write a STATIC `/etc/resolv.conf` from `bridgeIp` that nothing
|
||
# regenerates. Eval-proven: the generated
|
||
# `environment.etc."resolv.conf".text` is `nameserver <bridgeIp>`.
|
||
# This container always shares the host netns
|
||
# (`privateNetwork = false`), so it reaches `bridgeIp` regardless
|
||
# of agent-container isolation. See `docs/network.md`.
|
||
networking = {
|
||
# resolvconf is taken out of the loop entirely; the static
|
||
# `environment.etc."resolv.conf"` below is the sole source of
|
||
# the resolver file (no `nameservers` — nothing would read it).
|
||
useHostResolvConf = lib.mkForce false;
|
||
resolvconf.enable = lib.mkForce false;
|
||
};
|
||
|
||
# resolvconf is disabled above, so write the static resolver file
|
||
# explicitly — NixOS won't synthesise one from `nameservers` once
|
||
# resolvconf is off, and this is the file tuwunel parses at boot.
|
||
environment.etc."resolv.conf".text = ''
|
||
nameserver ${networkCfg.bridgeIp}
|
||
options edns0
|
||
'';
|
||
|
||
services.matrix-tuwunel = {
|
||
enable = true;
|
||
package = cfg.package;
|
||
settings.global = {
|
||
server_name = effectiveServerName;
|
||
# `address` + `port` are upstream `listOf` — wrap singles.
|
||
address = [ "0.0.0.0" ];
|
||
port = [ cfg.httpPort ];
|
||
max_request_size = cfg.maxRequestSize;
|
||
# Federation enabled at the protocol level; empty
|
||
# trustedServers keeps it effectively closed.
|
||
allow_federation = true;
|
||
trusted_servers = cfg.trustedServers;
|
||
# Token-gated registration. The absent
|
||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||
# keeps the server closed to anyone without the token.
|
||
allow_registration = true;
|
||
# LoadCredential below copies the host file into a
|
||
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||
# client always supports decryption regardless.
|
||
allow_encryption = cfg.allowEncryption;
|
||
# Tuwunel's default suffix is " 💕" — suppress it so agent
|
||
# display names are clean (just the agent name, no emoji).
|
||
new_user_displayname_suffix = "";
|
||
}
|
||
# `optionalAttrs`, not a key set to `[]`: with SSO off the
|
||
# rendered settings must be *exactly* what they were before
|
||
# this option existed, and an empty list is still a key.
|
||
// lib.optionalAttrs cfg.sso.enable {
|
||
# tuwunel's OIDC server and this list are the two ends of one
|
||
# pipe: `oidc_native_auth` stays false (its default), which
|
||
# upstream defines as "the OIDC server runs only to broker
|
||
# for a configured identity_provider". So the client-facing
|
||
# half needs no configuration — only the upstream half does.
|
||
identity_provider = [
|
||
{
|
||
# A free, case-insensitive string, not an enum: a
|
||
# recognised brand gets defaults and provider-specific
|
||
# workarounds, an unrecognised one simply gets neither.
|
||
# Which is why `issuer_url` below is not optional for us
|
||
# — the pre-supplied issuers cover public providers only.
|
||
brand = "authelia";
|
||
client_id = cfg.sso.clientId;
|
||
client_secret_file = matrixSecretCredential;
|
||
issuer_url = toString autheliaUrl;
|
||
callback_url = ssoCallbackUrl;
|
||
# Explicit, though a lone provider is auto-defaulted:
|
||
# relying on that logs a warning every startup, and a
|
||
# recurring warning that is expected is one nobody reads.
|
||
default = true;
|
||
|
||
# Upstream's rule is "only ever set `trusted` for
|
||
# identity providers you self-host and fully control",
|
||
# and this module cannot point anywhere else: the issuer
|
||
# is `swarm.authelia.url`, whose client, secret and user
|
||
# database are all ours. It does mean whoever can make
|
||
# authelia emit a given name gets that account — for our
|
||
# own identity provider that IS the identity.
|
||
# Without it, an SSO login cannot adopt an account that
|
||
# already exists; it can only ever create a new one.
|
||
trusted = true;
|
||
|
||
# One claim instead of upstream's ladder
|
||
# (`preferred_username` → `username` → `nickname` →
|
||
# `login` → `email`). The tail is the hazard: an email
|
||
# local part is a different namespace, so a login can
|
||
# land on a name that means someone else here.
|
||
userid_claims = [ "preferred_username" ];
|
||
|
||
# The default (`true`) makes a name collision SILENT —
|
||
# tuwunel invents a random localpart and the login
|
||
# succeeds as the wrong user. `false` errors instead,
|
||
# which is the only form of this an operator can act on.
|
||
unique_id_fallbacks = false;
|
||
}
|
||
];
|
||
};
|
||
};
|
||
# Keeps DynamicUser=true + PrivateUsers=true intact — no
|
||
# host-side chown :tuwunel / GID-pin gymnastics needed.
|
||
# See `man systemd.exec` → LoadCredential.
|
||
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
||
"registration_token:${toString cfg.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.
|
||
++ lib.optional cfg.sso.enable "oidc_client_secret:${toString cfg.sso.clientSecretFile}";
|
||
|
||
# Federation TLS against a peer whose cert chains to the swarm
|
||
# root: tuwunel's outbound client is reqwest with the `rustls`
|
||
# feature, which builds a `rustls_platform_verifier::Verifier`
|
||
# and — because tuwunel calls `tls_certs_merge` rather than
|
||
# `tls_certs_only` — keeps the platform roots alongside its
|
||
# compiled-in webpki set. On Linux that verifier resolves through
|
||
# `rustls-native-certs` → `openssl-probe`, which reads
|
||
# `SSL_CERT_FILE`. So the openssl-shaped variable IS the lever
|
||
# here, despite tuwunel linking no openssl.
|
||
#
|
||
# ⚠️ CONCATENATE, never point at the anchor alone. `openssl-probe`
|
||
# uses `SSL_CERT_FILE` *instead of* the default location, so
|
||
# naming just the hive bundle would drop every public CA and
|
||
# break federation with the wider matrix network — trading a
|
||
# small outage for a much larger one.
|
||
systemd.services.hive-matrix-ca-bundle = lib.mkIf useSelfSigned {
|
||
description = "assemble tuwunel TLS trust bundle (system CAs + hive CA)";
|
||
wantedBy = [ "tuwunel.service" ];
|
||
before = [ "tuwunel.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
SyslogIdentifier = "hive-matrix-ca-bundle";
|
||
};
|
||
path = [ pkgs.coreutils ];
|
||
script = ''
|
||
set -euo pipefail
|
||
install -d -m 0755 /run/hive-matrix-ca
|
||
cat /etc/ssl/certs/ca-certificates.crt ${caTrust.caContainerPath} \
|
||
> ${matrixCaBundle}
|
||
chmod 0644 ${matrixCaBundle}
|
||
'';
|
||
};
|
||
systemd.services.tuwunel.environment.SSL_CERT_FILE = lib.mkIf useSelfSigned matrixCaBundle;
|
||
|
||
environment.systemPackages = [ cfg.package ];
|
||
};
|
||
};
|
||
|
||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||
allowedTCPPorts = [
|
||
cfg.httpPort
|
||
];
|
||
};
|
||
|
||
# The matrix container's resolver is the hive's dnsmasq (bound at
|
||
# `bridgeIp`). Order the matrix container start after it so the
|
||
# resolver is up before tuwunel's first federation lookups. tuwunel
|
||
# boots fine without this — it configures the resolver from
|
||
# `/etc/resolv.conf` at startup and only queries on-demand (the boot
|
||
# failure this module guards against is an *empty* resolv.conf, a
|
||
# parse error, not a connectivity one) — so this is robustness, not a
|
||
# boot requirement. Soft `after` ordering (not `requires`) keeps the
|
||
# matrix container's lifecycle decoupled from the resolver's.
|
||
#
|
||
# `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also
|
||
# sets `after`/`requires` (so the bound trust bundle exists before
|
||
# nspawn wires the mount up), and two plain assignments to the same
|
||
# unit would conflict rather than combine.
|
||
systemd.services."container@hive-matrix" = lib.mkMerge [
|
||
{ after = [ "dnsmasq.service" ]; }
|
||
caTrust.containerOrdering
|
||
];
|
||
};
|
||
}
|