Agents in private netns can't reach host loopback directly; they use forge.<domain> and matrix.<domain> via the gateway. Update four locations that still claimed direct loopback access: - hive-forge.nix: module header + openFirewall description - hive-matrix.nix: openFirewall description - docs/matrix.md: container design bullet + openFirewall section
465 lines
20 KiB
Nix
465 lines
20 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 = "matrix.${hyperhiveDomain}";
|
|
defaultText = lib.literalExpression ''"matrix.''${services.hyperhive.domain}"'';
|
|
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 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.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` 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.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"
|
|
];
|
|
};
|
|
}
|