Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting is now `swarm-matrix-ctl mint`. Running rust inside `containers.hive-matrix` is not free: it needs its own store identity, its own cert role and its own bind mounts, and every one of those is per-*container*, not per-task. A second single-purpose crate would have had to duplicate that plumbing to add one action, so the next thing that has to run in there should be a verb here rather than a new crate. The old name guaranteed the opposite. `main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged. A bare invocation is refused: `mint` writes a credential, so "no verb" defaulting to it would make a typo in the unit mint rather than fail. The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`. Scoped to the verb and not to the binary, because a binary-scoped prefix is one the next verb has to share or widen, and a widened one never narrows again. A test asserts every variable carries the verb's prefix. The principal renames too. The cert role, bao policy, granting unit, leaf filename and `certAuthCns` entry all have to spell one string the same way, so leaving them as `swarm-matrix-minter` would have rebuilt the naming split this branch exists to remove. Renaming the nix options alongside is free here: every one of them is introduced by this PR and has never been released, so no operator config names them yet. `ExecStart` now names the verb, which is a contract between a nix string and a clap enum that fails at deploy time with no local signal. Both ends assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and a new module-eval arm reading the rendered `ExecStart`. docs/getting-started/setup.md drops the sender token from its "live on the host" list: setup does not touch this credential, so a setup guide has no reason to name it.
1478 lines
71 KiB
Nix
1478 lines
71 KiB
Nix
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.matrix;
|
||
networkCfg = config.services.hyperhive.network;
|
||
tlsCfg = config.services.hyperhive.deploy.hive-controller.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;
|
||
deployCfg = config.services.hyperhive.deploy;
|
||
|
||
# Where the swarm's secret store answers. Swarm-tier, identical on every
|
||
# host, which is what lets the container address it without knowing whether
|
||
# it stands here.
|
||
baoCfg = config.services.hyperhive.swarm.bao;
|
||
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 = deployCfg.authelia.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 host path 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 for the appservice registration below, 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";
|
||
|
||
# How this hive creates matrix accounts: an appservice registration whose
|
||
# `url` is null. Null is a legal `url` (ruma's `Registration` types it
|
||
# `Option<String>`), and it is the whole point — with no URL the homeserver
|
||
# never calls out, so there is no HTTP service to run and no daemon to
|
||
# operate. What the registration delivers is the `as_token`: hive-c0re's
|
||
# standing authority to create, and log in as, the accounts named by the
|
||
# namespace below, with no shared registration secret in the picture.
|
||
appserviceId = "hyperhive";
|
||
|
||
# The appservice's own user, and the account the hive acts as. An
|
||
# ordinary user, not a homeserver admin: everything the hive does with
|
||
# it — the Space, the chat room, the invites — it does as the creator of
|
||
# those rooms. Loading a registration CREATES its `sender_localpart` user
|
||
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user
|
||
# database, inside `Services::start()` — before the HTTP listener accepts
|
||
# anything, so the account exists on the very first boot of a fresh
|
||
# homeserver without hive-c0re racing to register it.
|
||
#
|
||
# ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives
|
||
# it independently with nothing wiring an override across — same
|
||
# agreement, and same reason for saying so, as the token path below.
|
||
hiveLocalpart = "hive";
|
||
|
||
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
|
||
# minted by the render script below, mode 0600; the `as_token` is the one
|
||
# hive-c0re reads and the one the swarm secret store overwrites (see
|
||
# `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver
|
||
# TO the appservice, which with `url = null` is nobody — it exists because
|
||
# the registration format requires it.
|
||
#
|
||
# `appserviceTokenPath` is the single source for the option's `default`,
|
||
# its `config`-level `mkDefault` self-definition, and the assertion that
|
||
# rejects a moved path. See `appserviceTokenFile`'s own comment below.
|
||
appserviceTokenPath = "/var/lib/hyperhive/matrix-appservice-token";
|
||
appserviceHsTokenPath = "/var/lib/hyperhive/matrix-appservice-hs-token";
|
||
|
||
# The registration file, and the directory that holds it. A directory
|
||
# rather than a `[global.appservice.<id>]` stanza because a stanza's
|
||
# `as_token` would be a nix literal, and a nix literal is a world-readable
|
||
# store path — the same rule that sends the OIDC client secret through a
|
||
# runtime file. `docs/swarm/secrets.md` has the general form of it.
|
||
appserviceDir = "/var/lib/hyperhive/matrix-appservice";
|
||
appserviceRegistrationPath = "${appserviceDir}/${appserviceId}.yaml";
|
||
|
||
# ⚠️ tuwunel does not read the host path directly, for exactly the reason
|
||
# given for the two secrets above: the file is 0600 root-owned and the
|
||
# homeserver runs under `DynamicUser=true`. `LoadCredential` copies it to a
|
||
# 0400 dynamic-user-owned path as root, before the sandbox and that user
|
||
# exist.
|
||
#
|
||
# So `appservice_dir` points at the credentials directory ITSELF. That is
|
||
# safe rather than clever: tuwunel's loader takes only `.yaml`/`.yml`
|
||
# entries from the directory and skips every other file, so the sibling
|
||
# credentials (the OIDC secret) are invisible to it. A credential id is a
|
||
# free-form filename, which is what lets ours end in `.yaml`.
|
||
appserviceCredentialId = "${appserviceId}-appservice.yaml";
|
||
appserviceCredentialDir = "/run/credentials/tuwunel.service";
|
||
|
||
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
||
#
|
||
# The oneshot that publishes the appservice sender account's access token to
|
||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
||
# because the appservice token that authorises the mint is already in here —
|
||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
||
# load it — and minting anywhere else would create a second holder of that
|
||
# secret, which is the thing this whole arrangement exists to stop.
|
||
#
|
||
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
|
||
# is the host least likely to also be the host running the store, so
|
||
# "co-located with bao" would leave the intended deployment silently minting
|
||
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
|
||
# states, for a sharper reason.
|
||
ctlActive =
|
||
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
|
||
|
||
# The role on the store's `cert` auth mount, and so the single source of the
|
||
# string both ends must agree on: ./swarm-bao.nix writes the role under
|
||
# `matrixCtlPolicyName` and this hands it to the binary in the
|
||
# environment, so the binary itself spells no role at all.
|
||
ctlCertRole = "swarm-matrix-ctl";
|
||
|
||
# Every host directory matrix-ctl's bao identity is spread across. Normally
|
||
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
|
||
# derived rather than assumed, because an operator naming paths by hand is
|
||
# exactly the deployment the gate above is written for.
|
||
#
|
||
# Directories rather than the files, for the reason `appserviceDir`'s own
|
||
# comment gives: a re-issued leaf is a new inode, and binding the file would
|
||
# pin the one the container saw when it started.
|
||
ctlPkiDirs = lib.optionals ctlActive (
|
||
lib.unique (
|
||
map builtins.dirOf (
|
||
[
|
||
deployCfg.matrix.ctlBaoClientCertFile
|
||
deployCfg.matrix.ctlBaoClientKeyFile
|
||
]
|
||
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
|
||
)
|
||
)
|
||
);
|
||
|
||
ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: {
|
||
hostPath = dir;
|
||
isReadOnly = true;
|
||
});
|
||
|
||
# Where a reader of the published credential is told the token is good for.
|
||
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
|
||
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
|
||
# the string "null" — which is what a hive with no gateway host actually
|
||
# knows about itself.
|
||
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
|
||
|
||
# Every local user this hive may provision — agents, `@hive:` itself, and
|
||
# the operator accounts `hivectl matrix create-user` makes, which is the
|
||
# whole matrix localpart charset.
|
||
#
|
||
# ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet`
|
||
# and asks it for a MATCH, not a full match, so an unanchored
|
||
# `@[a-z0-9]+:this.example` also matches `@x:this.example.evil.test`.
|
||
#
|
||
# Non-exclusive deliberately: an exclusive namespace does not widen what
|
||
# the appservice may do, it narrows what everything ELSE may do — an SSO
|
||
# login adopting an existing account, or `!admin users create-user`, would
|
||
# start failing with `M_EXCLUSIVE`. The appservice needs the right to
|
||
# create these names, not a monopoly on them.
|
||
appserviceUserRegex = "^@[a-z0-9._=/-]+:${lib.escapeRegex effectiveServerName}$";
|
||
|
||
# Mint the tokens if they are absent, then render the registration from
|
||
# whatever they now hold. **One script with two callers** — the activation
|
||
# script below and `glue-matrix-bao-token.nix`, which overwrites the
|
||
# `as_token` with the swarm store's copy and has to re-render afterwards.
|
||
#
|
||
# ⚠️ The `as_token` mint here is the FIRST-BOOT FALLBACK, not the normal
|
||
# route. `swarm-secret-publisher.nix` mints one per hive swarm-side and
|
||
# publishes it to the store; the reader above overwrites this file before
|
||
# the homeserver's container starts, so what a hive actually runs on is the
|
||
# swarm's value. This keeps a hive that has never reached the store working
|
||
# — and it fires only when the file is absent, so it never clobbers one the
|
||
# store delivered. (`hs_token` has no swarm half: it is how the homeserver
|
||
# authenticates to *us*, so it stays hive-local and is minted here for real.)
|
||
# A second copy of the registration's shape would be a second source of
|
||
# truth for a format whose mismatch is silent: the homeserver would load a
|
||
# registration naming a token nobody holds, and every request hive-c0re
|
||
# makes would come back 401 naming nothing.
|
||
#
|
||
# Re-rendering unconditionally is the point rather than thoroughness: "the
|
||
# token file exists" does not mean "the registration carries what is in
|
||
# it".
|
||
appserviceRegistrationScript = pkgs.writeShellApplication {
|
||
name = "hive-matrix-appservice-registration";
|
||
runtimeInputs = [ pkgs.coreutils ];
|
||
text = ''
|
||
# Both the tokens and the rendered registration are secrets; 077
|
||
# covers every file this script creates rather than each one
|
||
# separately.
|
||
umask 077
|
||
mkdir -p ${lib.escapeShellArg appserviceDir}
|
||
for f in ${lib.escapeShellArg appserviceTokenPath} ${lib.escapeShellArg appserviceHsTokenPath}; do
|
||
if [ ! -s "$f" ]; then
|
||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$f"
|
||
echo >> "$f"
|
||
echo "hive-matrix: generated appservice token at $f"
|
||
fi
|
||
chmod 0600 "$f"
|
||
done
|
||
|
||
# Read into shell variables and emitted with `printf`, a shell
|
||
# builtin: a token passed as an argument to a real command would land
|
||
# in that process's argv, which is world-readable for its lifetime.
|
||
asToken="$(cat ${lib.escapeShellArg appserviceTokenPath})"
|
||
hsToken="$(cat ${lib.escapeShellArg appserviceHsTokenPath})"
|
||
|
||
# The quoted heredoc keeps the regex's own `$` and `\` out of the
|
||
# shell's hands; the YAML single quotes keep them out of YAML's.
|
||
{
|
||
cat <<'REGISTRATION'
|
||
id: ${appserviceId}
|
||
url: null
|
||
sender_localpart: ${hiveLocalpart}
|
||
rate_limited: false
|
||
namespaces:
|
||
users:
|
||
- exclusive: false
|
||
regex: '${appserviceUserRegex}'
|
||
aliases: []
|
||
rooms: []
|
||
REGISTRATION
|
||
printf 'as_token: %s\nhs_token: %s\n' "$asToken" "$hsToken"
|
||
} > ${lib.escapeShellArg appserviceRegistrationPath}
|
||
chmod 0600 ${lib.escapeShellArg appserviceRegistrationPath}
|
||
chmod 0700 ${lib.escapeShellArg appserviceDir}
|
||
'';
|
||
};
|
||
|
||
# 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/integrations/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 chat.<swarm-domain>/ (gatewayHost's
|
||
# default). Container shape,
|
||
# serverName vs gatewayHost split, provisioning flow (registration
|
||
# token + LoadCredential), assertion rationale, initial rollout
|
||
# settings: docs/integrations/matrix.md. Vhost map + discovery flow + tuning
|
||
# knobs: docs/networking/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" ]
|
||
)
|
||
# "does THIS host run it" is a per-host decision, and `swarm.*` is the
|
||
# namespace every host in the swarm agrees on. What stays under
|
||
# `swarm.matrix` is the homeserver's identity as every hive sees it —
|
||
# its server name, its addresses, its federation policy.
|
||
(lib.mkRenamedOptionModule
|
||
[ "services" "hyperhive" "swarm" "matrix" "enable" ]
|
||
[ "services" "hyperhive" "deploy" "matrix" "enable" ]
|
||
)
|
||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "matrix" "sso" "enable" ] ''
|
||
SSO is no longer optional: a homeserver that runs at all delegates
|
||
login to the swarm's authelia.
|
||
|
||
Removed rather than defaulted to true so a config that turned it
|
||
OFF fails here, where the line is, instead of silently gaining a
|
||
login flow on the next rebuild. Drop the line; if it was false,
|
||
set services.hyperhive.deploy.matrix.sso.clientSecretFile and
|
||
services.hyperhive.swarm.authelia.url as the assertions describe.
|
||
'')
|
||
];
|
||
|
||
options.services.hyperhive.swarm.matrix = {
|
||
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 deployCfg.matrix.enable then "http://127.0.0.1:${toString cfg.httpPort}" else null;
|
||
defaultText = lib.literalExpression ''
|
||
if services.hyperhive.deploy.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/networking/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.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
# This homeserver always delegates login to the swarm's authelia, as
|
||
# an OIDC relying party — matrix SSO (`m.login.sso`), offered
|
||
# alongside password login. No toggle: a homeserver in a swarm is a
|
||
# client of that swarm's identity provider.
|
||
#
|
||
# ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which point
|
||
# the other way: those make this homeserver an *authorization server*
|
||
# for matrix clients. This family makes it a *client* of an external
|
||
# identity provider. The two share the protocol's name and answer
|
||
# opposite questions.
|
||
#
|
||
# This **adds** a way in. Password login keeps working: an identity
|
||
# provider that can take the homeserver offline when it hiccups is a
|
||
# worse homeserver than one with two ways in — which is also what
|
||
# makes always-on safe. Making authelia the *only* path is a
|
||
# separate, reversible switch (tuwunel's `login_with_password`),
|
||
# deliberately not folded in here.
|
||
#
|
||
# ⚠️ Matrix SSO lives **inside** the homeserver, never behind a
|
||
# forward-auth proxy: the client-server API is spoken by non-browser
|
||
# clients holding matrix access tokens — every agent's own
|
||
# `hive-matrix-daemon` — plus federation, and a proxy in front of
|
||
# `/_matrix/` breaks all of it.
|
||
sso = {
|
||
clientId = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "tuwunel";
|
||
description = ''
|
||
OAuth2 client id this homeserver identifies itself with. Must
|
||
match the `id` of the corresponding entry in
|
||
`services.hyperhive.swarm.authelia.oidc.clients`.
|
||
|
||
The secret it pairs with is a host path, so it lives at
|
||
`services.hyperhive.deploy.matrix.sso.clientSecretFile`.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
# What stays above is what the homeserver IS from any hive's point of view:
|
||
# the name it answers to, the ports and URLs it is reached on, and the client
|
||
# id it is registered under. What lives here is what the host running it
|
||
# decides — which build it runs, whether it is exposed, which peers it trusts,
|
||
# how large a request it accepts, and where its host-local secrets sit. Same rule as
|
||
# ./swarm-victorialogs.nix; `enable` already lives in ./deploy.nix, which also
|
||
# carries the renames.
|
||
options.services.hyperhive.deploy.matrix = {
|
||
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.
|
||
'';
|
||
};
|
||
|
||
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 `gatewayHost`'s vhost
|
||
(`chat.<swarm-domain>` by default) 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.deploy.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.
|
||
|
||
This is the only limit to set: the gateway's own body cap is
|
||
derived from it, with headroom, so the homeserver stays the one
|
||
that rejects an oversized upload.
|
||
'';
|
||
};
|
||
|
||
appserviceTokenFile = lib.mkOption {
|
||
type = lib.types.path;
|
||
internal = true;
|
||
default = appserviceTokenPath;
|
||
description = ''
|
||
Host path to a file containing this hive's matrix appservice
|
||
token (`as_token`) — the identity `hive-c0re` creates and logs
|
||
into accounts with. Minted automatically on first activation
|
||
(32-byte random hex, mode 0600) and rendered into the
|
||
appservice registration the homeserver loads at boot. Agents
|
||
never see it; an agent only ever receives its own
|
||
`access_token`.
|
||
|
||
Not operator-settable — `hive-c0re`'s Rust side derives this
|
||
same path independently (`paths::matrix_appservice_token()`)
|
||
with nothing wiring an override across, so a moved path desyncs
|
||
the two silently. An externally-managed token is delivered by
|
||
writing into *this* fixed path instead of moving it — see
|
||
`glue-matrix-bao-token.nix`, which fetches from the swarm secret
|
||
store, overwrites this file in place, and re-renders the
|
||
registration that names it.
|
||
|
||
Enforced by an `assertions` entry below rather than `readOnly`:
|
||
the `config` block gives this option its own `mkDefault`
|
||
definition (lowest priority) so a real override still resolves
|
||
cleanly instead of crashing eval with nixpkgs' generic
|
||
"read-only, set multiple times" message; the assertion then
|
||
names the actual problem. (`readOnly` was the first attempt on
|
||
this option's predecessor — atlas caught that it only rejects a
|
||
*second* definition, so with nothing else defining the option a
|
||
lone override sailed through silently, same desync as before
|
||
with a lock that wasn't locked. Fixed by defining the value here
|
||
instead of leaving it on `default` alone, but a proper
|
||
`assertions` message beat re-adding `readOnly` on top once the
|
||
option had a real definition either way.)
|
||
'';
|
||
};
|
||
|
||
appserviceRegistrationScript = lib.mkOption {
|
||
type = lib.types.path;
|
||
internal = true;
|
||
default = "${appserviceRegistrationScript}/bin/hive-matrix-appservice-registration";
|
||
defaultText = lib.literalMD "the module's own registration renderer";
|
||
description = ''
|
||
The script that mints the appservice tokens when absent and
|
||
(re-)renders the registration file from them. An option only so
|
||
that `glue-matrix-bao-token.nix` can run the same one after
|
||
overwriting the token with the swarm store's copy, rather than
|
||
carrying a second copy of the registration's shape.
|
||
|
||
Not operator-settable, and not a hook: replacing it means
|
||
deciding what the homeserver's appservice registration says,
|
||
which is this module's job.
|
||
'';
|
||
};
|
||
|
||
gui.enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = deployCfg.matrix.enable;
|
||
defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable";
|
||
description = ''
|
||
Serve a matrix web client at `gatewayHost`'s vhost (`chat.<swarm-domain>`
|
||
by default). Requires `gatewayHost != null`; the gateway itself always runs. When
|
||
off, the dashboard's `M4TR1X →` tab is hidden. See
|
||
`docs/networking/gateway.md` for the discovery flow that lets clients
|
||
auto-find the sub-domain. The client build itself is
|
||
`deploy.matrix.gui.package` — which client, as opposed to whether
|
||
this host serves it.
|
||
'';
|
||
};
|
||
|
||
gui.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 `gatewayHost`'s vhost
|
||
(`chat.<swarm-domain>` by default). 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.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.
|
||
|
||
Deliberately no fallback — a homeserver that boots with SSO
|
||
half-configured is worse than one that fails to evaluate:
|
||
tuwunel reads OIDC from its config file rather than a database
|
||
row, so a malformed block can stop the server outright instead
|
||
of merely hiding a button. The id this pairs with is
|
||
`swarm.matrix.sso.clientId`, which is swarm-wide because it must
|
||
match authelia's register.
|
||
'';
|
||
};
|
||
|
||
ctlPackage = lib.mkOption {
|
||
type = lib.types.package;
|
||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl";
|
||
description = ''
|
||
The `swarm-matrix-ctl` build run inside the matrix container.
|
||
|
||
⚠️ Named `ctlPackage`, not folded into `package` above: that one is
|
||
the homeserver, and this is a hyperhive binary that happens to run
|
||
beside it. Same split, and same reason, as
|
||
{option}`services.hyperhive.deploy.nats.authPackage`.
|
||
'';
|
||
};
|
||
|
||
ctlBaoClientCertFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
description = ''
|
||
Client certificate the matrix container's `swarm-matrix-ctl` presents to the
|
||
swarm's secret store. Its subject must be
|
||
{option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert
|
||
auth matches on the CN, and the role accepts nothing else.
|
||
|
||
⚠️ **Not the hive's own leaf**, and that is the whole deliverable of
|
||
giving this container an identity: the hive's certificate reads every
|
||
secret in the store, while this one may write a single path. Pointing
|
||
this at `deploy.bao.clientCertFile` would evaluate, deploy and work —
|
||
and give away the separation in one line.
|
||
|
||
No default: a module that guessed would be holding the CA opinion
|
||
./swarm-bao.nix deliberately does not hold.
|
||
./glue-matrix-ctl-bao-identity.nix points it at the leaf
|
||
./glue-bao-tls.nix mints, where this host mints one.
|
||
|
||
The file and its key are bind-mounted into the container read-only.
|
||
Co-located in the hive's filesystem and therefore readable by hive
|
||
**root** — accepted: the boundary this buys is identity (no hive
|
||
*process* holds the appservice token), not physical isolation.
|
||
'';
|
||
};
|
||
|
||
ctlBaoClientKeyFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
description = ''
|
||
Private key for
|
||
{option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`.
|
||
Both or neither — the unit does not exist unless each is set.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf deployCfg.matrix.enable {
|
||
# The option's own value, defined explicitly rather than left on its
|
||
# bare `default` — `mkDefault` so a real override still resolves
|
||
# cleanly rather than crashing eval; the `assertions` entry below is
|
||
# what actually rejects it, with a message naming the reason. See that
|
||
# option's own comment above.
|
||
services.hyperhive.deploy.matrix.appserviceTokenFile = lib.mkDefault appserviceTokenPath;
|
||
|
||
# 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;
|
||
services.hyperhive.gateway.enable = lib.mkIf (cfg.gatewayHost != null) (lib.mkDefault true);
|
||
|
||
# Unguarded, unlike the two above: the homeserver container resolves
|
||
# through the hive's dnsmasq whether or not the gateway fronts it.
|
||
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
||
|
||
# The homeserver's own journal (`tuwunel` is the unit name inside the
|
||
# container, whatever the nixpkgs option is called), plus the host-side
|
||
# oneshot that mints its OIDC secret — carrying the same `ssoLocal`
|
||
# guard the unit itself is declared under, so the list never names a
|
||
# unit this deployment does not define.
|
||
services.hyperhive.swarm.otel.journaldUnits = [
|
||
"tuwunel"
|
||
]
|
||
++ lib.optional ssoLocal "hive-matrix-oidc-secret";
|
||
|
||
# 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. Same
|
||
# `gatewayHost && gui.enable` condition the removed hive-dashboard
|
||
# H0M3 tile used to check via `state.matrix_gui_enabled` before this
|
||
# link became the only way in (docs/web-ui/dashboard.md::M4TR1X
|
||
# page). See `services.hyperhive.swarm.controller.links`'s
|
||
# description.
|
||
services.hyperhive.swarm.controller.links =
|
||
lib.optional (cfg.gatewayHost != null && deployCfg.matrix.gui.enable)
|
||
{
|
||
label = "Matrix";
|
||
icon = "💬";
|
||
url = "https://${cfg.gatewayHost}/";
|
||
};
|
||
|
||
# Accept-header SPA map, used only by the `/` location below (see
|
||
# docs/networking/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 deployCfg.matrix.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;
|
||
# Tracks `maxRequestSize` with headroom so the homeserver stays
|
||
# the tighter limit: its rejection is a matrix error a client can
|
||
# act on, where a 413 here names neither matrix nor the option the
|
||
# operator just raised. A second literal beside it was free to
|
||
# disagree, and did — raising the option past the old 50M changed
|
||
# nothing.
|
||
client_max_body_size ${toString (deployCfg.matrix.maxRequestSize + 1048576)};
|
||
proxy_read_timeout 1h;
|
||
proxy_send_timeout 1h;
|
||
${gatewayCfg.lib.securityHeaders}
|
||
add_header Access-Control-Allow-Origin *;
|
||
'';
|
||
};
|
||
}
|
||
// lib.optionalAttrs deployCfg.matrix.gui.enable {
|
||
# fluffychat at sub-domain root, SPA-fallback via the
|
||
# Accept-header `$matrix_spa_target` map above.
|
||
"/" = {
|
||
alias = "${deployCfg.matrix.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 (!deployCfg.matrix.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/integrations/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 = deployCfg.matrix.sso.clientSecretFile != null;
|
||
message = ''
|
||
This homeserver's SSO login flow requires
|
||
services.hyperhive.deploy.matrix.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 = autheliaUrl != null;
|
||
message = ''
|
||
This homeserver's SSO login flow 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.gatewayHost != null;
|
||
message = ''
|
||
This homeserver's SSO login flow 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.
|
||
'';
|
||
}
|
||
{
|
||
# mkDefault above lets an override resolve instead of crashing
|
||
# eval — this is the actual rejection, with a message that names
|
||
# the real fix instead of nixpkgs' generic conflicting-definition
|
||
# text.
|
||
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
||
message = ''
|
||
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
||
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust
|
||
side derives this same path independently and has no way to learn
|
||
an override, so moving it desyncs the two silently instead of
|
||
loudly.
|
||
|
||
Integrating an externally-managed appservice token? Deliver it
|
||
by writing into ${appserviceTokenPath} instead of pointing this
|
||
option elsewhere — see glue-matrix-bao-token.nix, which does
|
||
exactly that from the swarm secret store.
|
||
'';
|
||
}
|
||
];
|
||
|
||
# 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.deploy.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 appservice registration dodges that with an activation script
|
||
# that renders the file first. ⚠️ 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 "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
|
||
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"}
|
||
|
||
# authelia's container is up, but its first-boot generator may
|
||
# still be minting. Bounded wait, then fail: a silent skip here
|
||
# 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. This
|
||
# container 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/integrations/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
|
||
''
|
||
);
|
||
|
||
# Mint the appservice tokens and render the registration, before any
|
||
# container start.
|
||
#
|
||
# An activation script rather than a unit: the directory below is
|
||
# bind-mounted into the container and nixos-container refuses to start
|
||
# when a bind source is missing, so this has to have run first.
|
||
# Activation is what runs before the container both on a switch and on
|
||
# every boot. (The registration token this replaced used an activation
|
||
# script for the same reason, and additionally to dodge nspawn creating
|
||
# an empty file at a missing bind target — which tuwunel then read as
|
||
# "no token", refusing every registration until the next restart. A
|
||
# missing registration file is not silent in that way: the homeserver
|
||
# fails its appservice load loudly.)
|
||
system.activationScripts.hive-matrix-appservice = lib.stringAfter [ "var" ] ''
|
||
${deployCfg.matrix.appserviceRegistrationScript}
|
||
'';
|
||
|
||
containers.hive-matrix = {
|
||
autoStart = true;
|
||
ephemeral = false;
|
||
# Journal files on the host, not inside the container: nixpkgs hardcodes
|
||
# --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it.
|
||
extraFlags = [ "--link-journal=host" ];
|
||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||
privateNetwork = false;
|
||
# Read-only bind of the host-managed appservice registration; tuwunel
|
||
# reads it via systemd LoadCredential below (not directly).
|
||
#
|
||
# The directory, not the file inside it: the registration is
|
||
# re-rendered rather than edited, and binding the file would pin the
|
||
# inode the container saw when it started.
|
||
bindMounts = {
|
||
${appserviceDir} = {
|
||
hostPath = appserviceDir;
|
||
isReadOnly = true;
|
||
};
|
||
}
|
||
// caTrust.bindMount
|
||
# matrix-ctl's bao client identity, and nothing else of the hive's. See
|
||
# `ctlPkiDirs` above for why it is a derived set of directories
|
||
# rather than one literal.
|
||
// ctlBindMounts;
|
||
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/networking/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 = deployCfg.matrix.package;
|
||
settings.global = {
|
||
server_name = effectiveServerName;
|
||
# `address` + `port` are upstream `listOf` — wrap singles.
|
||
address = [ "0.0.0.0" ];
|
||
port = [ cfg.httpPort ];
|
||
max_request_size = deployCfg.matrix.maxRequestSize;
|
||
# Federation enabled at the protocol level; empty
|
||
# trustedServers keeps it effectively closed.
|
||
allow_federation = true;
|
||
trusted_servers = deployCfg.matrix.trustedServers;
|
||
# Nobody registers themselves here. Accounts are created by
|
||
# the hive's appservice, which this flag does not gate —
|
||
# tuwunel checks it only for requests that arrive WITHOUT an
|
||
# appservice token, so hive-c0re provisions exactly as before
|
||
# and everyone else is refused outright.
|
||
#
|
||
# ⚠️ Not a hardening afterthought: `allow_registration = true`
|
||
# with no registration token configured makes tuwunel REFUSE
|
||
# TO START (it demands
|
||
# `yes_i_am_very_very_sure_…_open_registration_…` instead). So
|
||
# dropping the token and leaving this true is not a lax
|
||
# homeserver, it is a homeserver that does not boot.
|
||
allow_registration = false;
|
||
|
||
# Where the hive's appservice registration is read from — the
|
||
# credentials directory, for the reasons at
|
||
# `appserviceCredentialDir`'s own definition. Loaded inside
|
||
# `Services::start()`, before the listener accepts anything.
|
||
appservice_dir = appserviceCredentialDir;
|
||
|
||
# No `admin_execute` promotion for `@${hiveLocalpart}`. The
|
||
# hive's account is an ordinary user: it creates the hive
|
||
# Space and chat room and invites agents into them, all of
|
||
# which ride on being the rooms' own creator at power level
|
||
# 100, and none of which is a homeserver-admin capability.
|
||
# Granting it server admin at boot would hand a credential
|
||
# that every hive reads far more than the work needs.
|
||
#
|
||
# The two operations that do need an admin sender —
|
||
# `!admin users make-user-admin` and
|
||
# `!admin users reset-password`, both messages into
|
||
# `#admins:${effectiveServerName}` — therefore have no
|
||
# working sender here. They are swarm-level operations and
|
||
# are being rehomed as such; until then they fail, loudly,
|
||
# rather than being served by an over-privileged 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 = "";
|
||
|
||
# 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 = [
|
||
# The credential id carries a `.yaml` suffix on purpose:
|
||
# `appservice_dir` above names this very directory, and tuwunel
|
||
# takes only `.yaml`/`.yml` entries from it.
|
||
"${appserviceCredentialId}:${appserviceRegistrationPath}"
|
||
# Same mechanism, second secret. tuwunel re-reads this file on
|
||
# every OAuth exchange, not just at startup, so it has to
|
||
# outlive the unit's start — a credentials path does.
|
||
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
|
||
];
|
||
|
||
# Publish the appservice sender account's access token to the swarm
|
||
# store, once, under an identity that belongs to this container and
|
||
# not to the hive. See `ctlActive` above for why it runs here.
|
||
#
|
||
# A `oneshot` with no timer and no retry loop of its own: the whole
|
||
# of "and only once" is the binary's first act, a read of the path it
|
||
# would write. `Restart=on-failure` covers a store that is sealed or
|
||
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
|
||
# set, because the unit having succeeded is not the idempotency
|
||
# record — the store is, and it outlives this machine.
|
||
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
|
||
description = "publish the matrix sender token to the swarm secret store";
|
||
# Ordered after the homeserver because both of the ladder's arms
|
||
# are client-server API calls. `wants`, not `requires`: a run that
|
||
# finds the credential already published never touches tuwunel at
|
||
# all, so a homeserver that is slow to come up should delay this,
|
||
# not cancel it.
|
||
after = [ "tuwunel.service" ];
|
||
wants = [ "tuwunel.service" ];
|
||
wantedBy = [ "multi-user.target" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
# The verb is part of the contract: `swarm-matrix-ctl` is a
|
||
# subcommand binary and refuses a bare invocation, so dropping
|
||
# `mint` here fails the unit rather than doing something else.
|
||
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
|
||
Restart = "on-failure";
|
||
RestartSec = 30;
|
||
# Bounded here rather than left to systemd's default, for the
|
||
# reason ./swarm-secret-publisher.nix states: a sealed store
|
||
# answers on the port and never answers the read.
|
||
TimeoutStartSec = 60;
|
||
SyslogIdentifier = "swarm-matrix-ctl";
|
||
};
|
||
environment = {
|
||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
|
||
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
|
||
MATRIX_MINT_CERT_ROLE = ctlCertRole;
|
||
# Loopback: this container shares the host netns, so the
|
||
# homeserver it must talk to is the one in this very unit's
|
||
# netns and needs no name, no vhost and no TLS.
|
||
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
|
||
# The bind-mounted registration, which IS the as_token. A path,
|
||
# never a value.
|
||
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
|
||
MATRIX_MINT_LOCALPART = hiveLocalpart;
|
||
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
|
||
}
|
||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
||
};
|
||
};
|
||
|
||
environment.systemPackages = [ deployCfg.matrix.package ];
|
||
};
|
||
};
|
||
|
||
networking.firewall = lib.mkIf deployCfg.matrix.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
|
||
];
|
||
};
|
||
}
|