hyperhive/nix/host-modules/hive-matrix.nix
atlas 6caf177416 refactor(3202): matrix declares its own vhost, dns name and SPA map
Moves the matrix sub-domain vhost out of the gateway's vhosts.nix, its
`address=` rule out of dnsmasq.nix, and the Accept-header
`$matrix_spa_target` map out of the gateway's appendHttpConfig — all
three into hive-matrix.nix.

The map is the one that had no business being where it was: it exists
solely for the SPA fallback in the vhost's `/` location, and
`appendHttpConfig` is a `lines` option, so a module can contribute to
it without the gateway assembling it.

The `.well-known/matrix/*` delegation deliberately stays on the hive's
own vhost. The spec requires it at the SERVER NAME, which is the hive
domain: that is the hive answering "where is my homeserver", not the
homeserver answering for itself. Moving it would have been the obvious
symmetric thing and it would have been wrong.
2026-08-13 16:14:37 +02:00

683 lines
30 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.swarm.matrix;
networkCfg = config.services.hyperhive.network;
tlsCfg = config.services.hyperhive.tls;
gatewayCfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
# Same runtime→build-time bridge hive-ci and hive-forge already cross:
# binds the hive trust bundle (which folds in the swarm root) into the
# container and orders the container after `hive-tls-ca.service`. The
# *consumption* is per-runtime and stays here — see the bundle service
# in the container config below.
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
useSelfSigned = caTrust.useSelfSigned;
# tuwunel's own combined bundle, assembled at start. /run is tmpfs, so
# it is rebuilt from the current CA every boot rather than going stale.
matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt";
swarmDomain = config.services.hyperhive.swarm.domain;
# 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.
'';
};
};
};
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;
# 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".
'';
}
];
# Activation-time token generation — without this the bind-mount
# would hand tuwunel an empty file on first boot and break every
# registration until restart. Idempotent;
# docs/matrix.md::Provisioning flow.
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
if [ ! -s "$tokenFile" ]; then
mkdir -p "$(dirname "$tokenFile")"
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
echo >> "$tokenFile"
echo "hive-matrix: generated registration token at $tokenFile"
fi
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
chmod 0600 "$tokenFile"
'';
containers.hive-matrix = {
autoStart = true;
ephemeral = false;
# Shared host netns — agents reach tuwunel at localhost:<port>.
privateNetwork = false;
# Read-only bind of the host-managed registration token; tuwunel
# reads it via systemd LoadCredential below (not directly).
bindMounts = {
${cfg.registrationTokenFile} = {
hostPath = cfg.registrationTokenFile;
isReadOnly = true;
};
}
// caTrust.bindMount;
config =
{ ... }:
{
system.stateVersion = "26.05";
# Shared host netns: this container's own firewall.service
# would rewrite the HOST ruleset (flush nixos-fw, drop the
# host's nixos-nat-* chains) at every boot — killing the
# bridge DHCP/DNS holes and agent NAT. The host firewall owns
# all filtering; never run one in here.
networking.firewall.enable = false;
# Swarm-internal trust reaches tuwunel at RUNTIME, not via
# `security.pki.certificateFiles`. That option is read when the
# system is BUILT, and the swarm root is deliberately a runtime
# file (`swarm.ca.stateDir`) because its key must never enter the
# store — so there is nothing build-time to name. The bind-mount
# above plus the bundle service below are what replaced it.
# tuwunel hard-fails to boot if `/etc/resolv.conf` has no
# `nameserver` line (`Failed to configure DNS resolver ... no
# nameservers found in config` → exit 1). This declarative
# nixos-container comes up with an EMPTY resolv.conf even with
# `networking.nameservers` set: the nixos-container default
# `useHostResolvConf = true` puts in-container resolvconf in
# host-tracking mode (ignores `networking.nameservers`, and never
# gets the host file across the shared-netns boundary), so it
# regenerates an empty file and tuwunel dies at boot.
#
# Trusting resolvconf to honour `networking.nameservers` doesn't
# work either — that's a RUNTIME resolvconf behaviour, not
# verifiable at eval time, and it still comes up empty in
# practice. So take resolvconf out of the loop entirely and
# write a STATIC `/etc/resolv.conf` from `bridgeIp` that nothing
# regenerates. Eval-proven: the generated
# `environment.etc."resolv.conf".text` is `nameserver <bridgeIp>`.
# This container always shares the host netns
# (`privateNetwork = false`), so it reaches `bridgeIp` regardless
# of agent-container isolation. See `docs/network.md`.
networking = {
# resolvconf is taken out of the loop entirely; the static
# `environment.etc."resolv.conf"` below is the sole source of
# the resolver file (no `nameservers` — nothing would read it).
useHostResolvConf = lib.mkForce false;
resolvconf.enable = lib.mkForce false;
};
# resolvconf is disabled above, so write the static resolver file
# explicitly — NixOS won't synthesise one from `nameservers` once
# resolvconf is off, and this is the file tuwunel parses at boot.
environment.etc."resolv.conf".text = ''
nameserver ${networkCfg.bridgeIp}
options edns0
'';
services.matrix-tuwunel = {
enable = true;
package = cfg.package;
settings.global = {
server_name = effectiveServerName;
# `address` + `port` are upstream `listOf` — wrap singles.
address = [ "0.0.0.0" ];
port = [ cfg.httpPort ];
max_request_size = cfg.maxRequestSize;
# Federation enabled at the protocol level; empty
# trustedServers keeps it effectively closed.
allow_federation = true;
trusted_servers = cfg.trustedServers;
# Token-gated registration. The absent
# `yes_i_am_very_very_sure_…_open_registration_…` flag
# keeps the server closed to anyone without the token.
allow_registration = true;
# LoadCredential below copies the host file into a
# 0400 dynamic-user-owned path; tuwunel reads from there.
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
# Server-side E2EE is opt-in (default off); the agent matrix
# client always supports decryption regardless.
allow_encryption = cfg.allowEncryption;
# Tuwunel's default suffix is " 💕" — suppress it so agent
# display names are clean (just the agent name, no emoji).
new_user_displayname_suffix = "";
};
};
# 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}"
];
# Federation TLS against a peer whose cert chains to the swarm
# root: tuwunel's outbound client is reqwest with the `rustls`
# feature, which builds a `rustls_platform_verifier::Verifier`
# and — because tuwunel calls `tls_certs_merge` rather than
# `tls_certs_only` — keeps the platform roots alongside its
# compiled-in webpki set. On Linux that verifier resolves through
# `rustls-native-certs` → `openssl-probe`, which reads
# `SSL_CERT_FILE`. So the openssl-shaped variable IS the lever
# here, despite tuwunel linking no openssl.
#
# ⚠️ CONCATENATE, never point at the anchor alone. `openssl-probe`
# uses `SSL_CERT_FILE` *instead of* the default location, so
# naming just the hive bundle would drop every public CA and
# break federation with the wider matrix network — trading a
# small outage for a much larger one.
systemd.services.hive-matrix-ca-bundle = lib.mkIf useSelfSigned {
description = "assemble tuwunel TLS trust bundle (system CAs + hive CA)";
wantedBy = [ "tuwunel.service" ];
before = [ "tuwunel.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "hive-matrix-ca-bundle";
};
path = [ pkgs.coreutils ];
script = ''
set -euo pipefail
install -d -m 0755 /run/hive-matrix-ca
cat /etc/ssl/certs/ca-certificates.crt ${caTrust.caContainerPath} \
> ${matrixCaBundle}
chmod 0644 ${matrixCaBundle}
'';
};
systemd.services.tuwunel.environment.SSL_CERT_FILE = lib.mkIf useSelfSigned matrixCaBundle;
environment.systemPackages = [ cfg.package ];
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.httpPort
];
};
# The matrix container's resolver is the hive's dnsmasq (bound at
# `bridgeIp`). Order the matrix container start after it so the
# resolver is up before tuwunel's first federation lookups. tuwunel
# boots fine without this — it configures the resolver from
# `/etc/resolv.conf` at startup and only queries on-demand (the boot
# failure this module guards against is an *empty* resolv.conf, a
# parse error, not a connectivity one) — so this is robustness, not a
# boot requirement. Soft `after` ordering (not `requires`) keeps the
# matrix container's lifecycle decoupled from the resolver's.
#
# `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also
# sets `after`/`requires` (so the bound trust bundle exists before
# nspawn wires the mount up), and two plain assignments to the same
# unit would conflict rather than combine.
systemd.services."container@hive-matrix" = lib.mkMerge [
{ after = [ "dnsmasq.service" ]; }
caTrust.containerOrdering
];
};
}