Add swarm.peers.<domain>.caCert (path to a peer hive's root CA PEM), trusted everywhere the hive's own internal CA is — so a self-signed peer hive can federate (matrix) and any in-hive consumer validates its certs. Mechanism (reuses the existing hive-CA embedding): the meta-flake renderer embeds a LIST of CA files next to each agent's flake — hive-ca.pem (the hive's own self-signed CA, when active) plus each peer caCert as peer-ca-<N>.pem — and emits them all in security.pki.certificateFiles, so every agent trusts them at build time. The matrix container trusts the same peer CAs for federation TLS. Nothing is installed in the host trust store; the certs live in the nix store (no mutable host file). - meta.rs: embedded_ca_files() = hive CA + peer CAs (from new HIVE_PEER_CA_PATHS env); ca_embed_state() tracks the list (content + add/remove); sync_agents materialises + stages the list; render emits the multi-entry certificateFiles. Tests cover hive-only / hive+peers / peers-only / none. - hive-c0re.nix: HIVE_PEER_CA_PATHS service env (colon-joined caCerts); caCert / certFingerprint option docs updated to the hive-wide scope. - hive-matrix.nix + docs/swarm.md: scope + comment updates. certFingerprint stays the c0re-only leaf-pin path.
482 lines
21 KiB
Nix
482 lines
21 KiB
Nix
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.matrix;
|
|
networkCfg = config.services.hyperhive.network;
|
|
hyperhiveDomain = config.services.hyperhive.domain;
|
|
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
|
|
|
|
# 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.
|
|
|
|
options.services.hyperhive.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. Off by default while
|
|
the integration phases in; flip to `true` once the operator
|
|
has set `services.hyperhive.domain` and is ready to onboard agents.
|
|
'';
|
|
};
|
|
|
|
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.domain` (the bare hive domain). Combined
|
|
with the `.well-known/matrix/{client,server}` routes the
|
|
hive-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.
|
|
`matrix.<domain>` if you want the subdomain split, or
|
|
`chat.example.org` for a bespoke hostname).
|
|
|
|
**Breaking change**: this used to default to
|
|
`matrix.''${services.hyperhive.domain}`. matrix IDs embed
|
|
the server_name irrevocably, so existing homeservers must
|
|
set `services.hyperhive.matrix.serverName = "matrix.''${services.hyperhive.domain}";`
|
|
explicitly to preserve their existing user / room IDs
|
|
before rebuilding.
|
|
'';
|
|
};
|
|
|
|
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.
|
|
'';
|
|
};
|
|
|
|
gatewayHost = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null;
|
|
defaultText = lib.literalExpression ''
|
|
if services.hyperhive.domain != null then
|
|
"matrix.''${services.hyperhive.domain}"
|
|
else
|
|
null
|
|
'';
|
|
example = "matrix.example.com";
|
|
description = ''
|
|
Public hostname for the matrix homeserver behind the gateway.
|
|
Defaults to `matrix.''${services.hyperhive.domain}` (sub-domain
|
|
shape — see `docs/gateway.md`). 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.
|
|
|
|
Note: `gatewayHost` is the API listener hostname (where nginx
|
|
proxies `/_matrix/*`); `serverName` is the matrix-identifier
|
|
domain embedded irrevocably in user/room IDs (default = bare
|
|
hive-domain). The two are distinct.
|
|
'';
|
|
};
|
|
|
|
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 homeserver is reachable from the
|
|
host + every agent container via `localhost` either way
|
|
(shared netns), 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.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.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 {
|
|
# serverName must exist (irrevocably embedded in user/room IDs);
|
|
# gatewayHost may not be "" (same footgun as forge.domain —
|
|
# nginx rejects an empty server_name). docs/matrix.md::Assertion
|
|
# rationale.
|
|
assertions = [
|
|
{
|
|
assertion = hyperhiveDomain != null || cfg.serverName != null;
|
|
message = ''
|
|
services.hyperhive.matrix.enable = true requires either:
|
|
- services.hyperhive.domain set to your host's canonical domain
|
|
(recommended; shared with forge / dashboard), or
|
|
- services.hyperhive.matrix.serverName set explicitly.
|
|
|
|
The matrix server_name is embedded into every user ID and
|
|
room ID on this homeserver — it cannot be changed later
|
|
without losing every account and chat history. Pick a
|
|
stable hostname before enabling.
|
|
'';
|
|
}
|
|
{
|
|
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
|
|
message = ''
|
|
services.hyperhive.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;
|
|
};
|
|
config =
|
|
{ ... }:
|
|
{
|
|
system.stateVersion = "26.05";
|
|
|
|
# Peer-hive root CAs (`swarm.peers.<domain>.caCert`) added to THIS
|
|
# container's trust bundle so tuwunel validates *federation* TLS
|
|
# from a self-signed peer hive (it checks the peer's federation
|
|
# cert against its trust bundle). Peer CAs are trusted everywhere
|
|
# the hive's own internal CA is — agents get them via the
|
|
# meta-flake renderer (`HIVE_PEER_CA_PATHS` → each agent's
|
|
# `security.pki.certificateFiles`); this block is the matrix
|
|
# container's copy, since the host `security.pki` store doesn't
|
|
# cross the container boundary. They are never installed in the
|
|
# HOST trust store. Null entries (CA-bundle / fingerprint-pinned
|
|
# peers) drop out.
|
|
security.pki.certificateFiles = lib.filter (c: c != null) (
|
|
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
|
|
);
|
|
|
|
# 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.
|
|
#
|
|
# The earlier fix turned host-tracking off and trusted resolvconf
|
|
# to honour `networking.nameservers` — but that's a RUNTIME
|
|
# resolvconf behaviour, not verifiable at eval time, and it STILL
|
|
# came 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. Network module off → inherit the host's
|
|
# resolv.conf. See `docs/network.md`.
|
|
networking = lib.mkMerge [
|
|
(lib.mkIf networkCfg.enable {
|
|
# 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;
|
|
})
|
|
(lib.mkIf (!networkCfg.enable) {
|
|
useHostResolvConf = true;
|
|
})
|
|
];
|
|
|
|
# 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 = lib.mkIf networkCfg.enable {
|
|
"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}"
|
|
];
|
|
environment.systemPackages = [ cfg.package ];
|
|
};
|
|
};
|
|
|
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
|
allowedTCPPorts = [
|
|
cfg.httpPort
|
|
];
|
|
};
|
|
|
|
# When the hive network module is on, the matrix container's resolver
|
|
# is the dnsmasq that runs in the gateway container (bound at
|
|
# `bridgeIp`). Order the matrix container start after the gateway
|
|
# container 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 fixes was 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 gateway's. The gateway
|
|
# always runs alongside hyperhive, so the gateway container unit always
|
|
# exists here. (Declarative `containers.<n>` → `container@<n>.service` — the
|
|
# nspawn template NixOS generates, confirmed from the live
|
|
# `container@hive-matrix.service` host unit.)
|
|
systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [
|
|
"container@hive-gateway.service"
|
|
];
|
|
};
|
|
}
|