`serverName` is baked irrevocably into every user and room id, so a hive that rebuilds onto a new default is a *different homeserver*, not a renamed one: existing accounts and rooms are stranded, and reverting the config does not undo it. Its neighbours (`gatewayHost`, the forge domain) are routing, rediscovered through `.well-known` and fixed by editing them back. Same diff shape, three orders of magnitude apart in blast radius -- which is an asymmetry a module should carry rather than an operator. An activation script and not `warnings`, which is where this obviously belongs and does not work: the condition needs the host filesystem, and `nixos-rebuild switch --flake` evaluates purely, where `builtins.pathExists "/var/lib/..."` answers false rather than throwing. A `warnings` entry gated on it would evaluate, deploy, and print nothing on every real deployment. Rendered only when `serverName` is null, so a pinned hive has no script rather than a script that stays quiet -- a guard that cries wolf at a correctly-configured deployment makes the next real one read as noise. Never fails the activation: it warns about a choice that cannot be undone, and refusing the rebuild of a hive that already chose deliberately is the opposite of helping. The probed path is read out of the container's own evaluated config rather than hardcoded. A guessed path resolves cleanly and silently never matches, which is the same failure this guard exists to catch one level up.
1028 lines
47 KiB
Nix
1028 lines
47 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
|
||
# assembled bundle itself comes from `caTrust.trustBundle`, imported in
|
||
# the container config below.
|
||
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||
|
||
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.
|
||
# Tell an operator whose homeserver already exists that `serverName` is
|
||
# unpinned, at the one moment they are looking: the rebuild.
|
||
#
|
||
# ⚠️ An activation script and NOT `warnings`, which is where this
|
||
# obviously belongs and does not work. The condition needs the host
|
||
# filesystem — does a homeserver already exist here? — and
|
||
# `nixos-rebuild switch --flake` evaluates PURELY, where
|
||
# `builtins.pathExists "/var/lib/…"` answers **false** rather than
|
||
# throwing. A `warnings` entry gated on it would evaluate, deploy, and
|
||
# print nothing, on every real deployment. Same shape as an option whose
|
||
# consumer is disabled: renders perfectly, does nothing.
|
||
#
|
||
# Only rendered when `serverName` is null, so a hive that pinned it
|
||
# cannot be nagged — the script does not exist there rather than
|
||
# existing and choosing to stay quiet. A guard that cries wolf at a
|
||
# correctly-configured deployment is worse than no guard, because the
|
||
# next real one is read as noise too.
|
||
#
|
||
# Never fails. This warns about a choice that cannot be undone; refusing
|
||
# the activation would break the rebuild of a hive that had already made
|
||
# that choice deliberately, which is the opposite of helping.
|
||
system.activationScripts.hive-matrix-servername-pin = lib.mkIf (cfg.serverName == null) (
|
||
lib.stringAfter [ "var" ] ''
|
||
# The homeserver's own database, asked of the container's evaluated
|
||
# config rather than hardcoded: a guessed path resolves cleanly and
|
||
# silently never matches, which is exactly the failure this guard
|
||
# exists to avoid one level up.
|
||
dbDir=${
|
||
lib.escapeShellArg (
|
||
# Same host-side container-root prefix this module already writes
|
||
# by hand for the SSO secret copy above — not a second convention.
|
||
"/var/lib/nixos-containers/hive-matrix"
|
||
+ config.containers.hive-matrix.config.services.matrix-tuwunel.settings.global.database_path
|
||
)
|
||
}
|
||
if [ -d "$dbDir" ]; then
|
||
echo "hive-matrix: WARNING — services.hyperhive.swarm.matrix.serverName is unset, and this host already has a homeserver at $dbDir."
|
||
echo "hive-matrix: it is defaulting to ${effectiveServerName}, which is baked into every NEW user and room id."
|
||
echo "hive-matrix: if ids here were minted under a different name, existing accounts and rooms are stranded — reverting the config does NOT undo it."
|
||
echo "hive-matrix: pin whichever name this homeserver already uses, e.g.:"
|
||
echo "hive-matrix: services.hyperhive.swarm.matrix.serverName = \"''${HIVE_MATRIX_EXISTING_SERVER_NAME:-<the name already in use>}\";"
|
||
fi
|
||
''
|
||
);
|
||
|
||
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 =
|
||
{ ... }:
|
||
{
|
||
imports = [
|
||
# tuwunel's rustls verifier resolves through `rustls-native-certs`
|
||
# → `openssl-probe`, which reads `SSL_CERT_FILE` — so the
|
||
# openssl-shaped variable is the lever despite tuwunel linking no
|
||
# openssl.
|
||
#
|
||
# ⚠️ The helper CONCATENATES, and that is load-bearing here beyond
|
||
# the usual reason: `SSL_CERT_FILE` replaces the default location,
|
||
# so naming the hive anchor alone would drop every public CA and
|
||
# break federation with the wider matrix network — trading a small
|
||
# outage for a much larger one.
|
||
# A literal, not an option: this module names its container
|
||
# `containers.hive-matrix` directly and declares no `machine`
|
||
# option to derive it from.
|
||
(caTrust.trustBundle {
|
||
inherit pkgs;
|
||
name = "hive-matrix";
|
||
consumers = [ "tuwunel" ];
|
||
})
|
||
];
|
||
|
||
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}";
|
||
|
||
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
|
||
];
|
||
};
|
||
}
|