322 lines
14 KiB
Nix
322 lines
14 KiB
Nix
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.matrix;
|
||
hyperhiveDomain = config.services.hyperhive.domain;
|
||
effectiveServerName =
|
||
if cfg.serverName != null then cfg.serverName else "matrix.${hyperhiveDomain}";
|
||
in
|
||
{
|
||
# Private Matrix homeserver (matrix-tuwunel — the official conduwuit
|
||
# successor) for hyperhive agents, wrapped in a nixos-container so it
|
||
# doesn't fight any existing `services.matrix-*` the operator may
|
||
# already run on the host. Same shape as `nix/modules/hive-forge.nix`:
|
||
# shared host netns (`privateNetwork = false`) so agents reach it at
|
||
# `http://localhost:<httpPort>` (or via the configured server_name
|
||
# for federation), nixos-container only here for state + systemd-unit
|
||
# isolation.
|
||
#
|
||
# Container name `hive-matrix` (not `h-*`) so the lifecycle scanner
|
||
# ignores it; operator manages via the standard `nixos-container` CLI.
|
||
#
|
||
# Persistent state at `/var/lib/nixos-containers/hive-matrix/var/lib/
|
||
# matrix-tuwunel/` (survives container restart / host reboot). To
|
||
# wipe, destroy the container.
|
||
#
|
||
# Initial rollout (#548): federation enabled (needed for multi-hive
|
||
# swarms; trusted_servers starts empty so no actual federation traffic
|
||
# leaves until peers are explicitly listed), registration enabled via
|
||
# a `registration_token_file` known only to hive-c0re (so agents can't
|
||
# self-register without going through the coordinator), e2ee disabled
|
||
# per operator call (tracked for follow-up at #551).
|
||
#
|
||
# Provisioning model (matches `nix/modules/hive-forge.nix` shape):
|
||
# hive-c0re generates a 32-byte random `registration_token` on first
|
||
# boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode
|
||
# 0600, root-only), and bind-mounts that file read-only into the
|
||
# tuwunel container at the same path so tuwunel can read it via
|
||
# `registration_token_file`. hive-c0re then uses the token to register
|
||
# each agent account via the matrix-spec UIAA registration flow, and
|
||
# persists the returned `access_token` to `<agent-state>/matrix-token`
|
||
# so the agent's matrix MCP client can authenticate without ever
|
||
# seeing the shared registration token.
|
||
|
||
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
|
||
`matrix.''${services.hyperhive.domain}` (always a subdomain — keeps
|
||
the root domain free for the dashboard or forge). Override
|
||
here only if you need a name that doesn't follow the
|
||
`matrix.<domain>` shape.
|
||
'';
|
||
};
|
||
|
||
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, manager
|
||
8000, sub-agents 8100..8999). Federation listens on
|
||
`federationPort` separately.
|
||
'';
|
||
};
|
||
|
||
openFirewall = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = true;
|
||
description = ''
|
||
Open `httpPort` in the host firewall. Off when the
|
||
homeserver should only be reachable from inside the host
|
||
(e.g. while bringing the integration up before announcing
|
||
it to other hives).
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
gui = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = cfg.enable;
|
||
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
|
||
description = ''
|
||
Mount a matrix web client (default `pkgs.fluffychat-web`) as
|
||
a static sibling of the dashboard at `/matrix/`, served by
|
||
hive-c0re's own axum router via the `HIVE_MATRIX_GUI_DIR`
|
||
env var (#607). Defaults to whatever
|
||
`services.hyperhive.matrix.enable` is — turning on the
|
||
homeserver gives you the web client by default; set to
|
||
`false` explicitly to opt out of the GUI while keeping
|
||
the homeserver running for agents.
|
||
|
||
Same-origin via hive-c0re is the simplest single-host
|
||
shape; the post-#15 nginx-front re-root (`https://matrix.''${services.hyperhive.domain}`)
|
||
is tracked separately in #609. fluffychat-web supports
|
||
per-login server pick — point it at the in-host tuwunel URL
|
||
(`http://localhost:8008` by default) the first time.
|
||
'';
|
||
};
|
||
|
||
package = lib.mkOption {
|
||
type = lib.types.package;
|
||
default = pkgs.fluffychat-web.overrideAttrs (old: {
|
||
# fluffychat-web ships with `<base href="/">` baked into its
|
||
# index.html — that's the placeholder flutter would replace
|
||
# via `--base-href` at build time. Since hyperhive serves the
|
||
# dist at `/matrix/` (not the document root), the literal
|
||
# `<base href="/">` would resolve every relative asset path
|
||
# (`Imaging.js`, `flutter.js`, `splash/*`, etc.) against the
|
||
# ROOT of the origin, leading to 404s on every load (#634).
|
||
# Patch the placeholder to `<base href="/matrix/">` at install
|
||
# time so the SPA boots correctly under the sub-path mount.
|
||
# If/when we move to subdomain routing (#609 −
|
||
# `matrix.${hyperhiveDomain}`), drop this override and revert
|
||
# to upstream `pkgs.fluffychat-web` — it'll be at the root
|
||
# again.
|
||
postInstall = (old.postInstall or "") + ''
|
||
${pkgs.gnused}/bin/sed -i \
|
||
's|<base href="/">|<base href="/matrix/">|' \
|
||
$out/index.html
|
||
'';
|
||
});
|
||
defaultText = lib.literalExpression "pkgs.fluffychat-web (patched for /matrix/ base href)";
|
||
description = ''
|
||
Static web client dist to serve at `/matrix/`. Defaults to
|
||
`pkgs.fluffychat-web` (with the `<base href>` patched to
|
||
`/matrix/` so relative asset paths resolve under the
|
||
sub-path mount, #634). Override to swap for `hydrogen-web`
|
||
(lightest), `cinny` (no threads), `element-web` (heaviest,
|
||
full features), or an out-of-tree client dist — any
|
||
replacement also needs its `<base href>` aligned with the
|
||
mount path.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf cfg.enable {
|
||
# mara on #548: "there is no default, but it is required. add
|
||
# assertion." — fail eval with a helpful message rather than
|
||
# spawning a homeserver with a bogus server_name we can never
|
||
# change later. `services.hyperhive.domain` is host-wide; matrix derives
|
||
# the server_name from it (or from `cfg.serverName` if the
|
||
# operator wants to override).
|
||
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.
|
||
'';
|
||
}
|
||
];
|
||
|
||
# Generate the registration token at system activation time, BEFORE
|
||
# the hive-matrix container would otherwise start with an empty
|
||
# bind-mount target (argus nit on #565: nspawn creates an empty
|
||
# file when the host path is missing, tuwunel reads it as
|
||
# `registration_token_file=""` and rejects every registration
|
||
# until the next restart). Idempotent: only writes when the file
|
||
# doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's
|
||
# `matrix::ensure_register_token` would produce.
|
||
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"
|
||
chmod 600 "$tokenFile"
|
||
echo "hive-matrix: generated registration token at $tokenFile"
|
||
fi
|
||
'';
|
||
|
||
containers.hive-matrix = {
|
||
autoStart = true;
|
||
ephemeral = false;
|
||
# Share host netns — tuwunel's listeners look exactly like
|
||
# host-side services, no port-forward plumbing, and agent
|
||
# containers (also host netns) reach it via plain `localhost`.
|
||
privateNetwork = false;
|
||
# Read-only bind of the host-managed registration token so
|
||
# tuwunel can resolve `registration_token_file` to a real
|
||
# file inside the container. The activation script above
|
||
# ensures the host path exists with a valid 64-char hex token
|
||
# before any container starts, so the bind always finds real
|
||
# content (no first-boot empty-file race; argus #565 nit).
|
||
bindMounts.${cfg.registrationTokenFile} = {
|
||
hostPath = cfg.registrationTokenFile;
|
||
isReadOnly = true;
|
||
};
|
||
config =
|
||
{ ... }:
|
||
{
|
||
system.stateVersion = "26.05";
|
||
services.matrix-tuwunel = {
|
||
enable = true;
|
||
package = cfg.package;
|
||
settings.global = {
|
||
server_name = effectiveServerName;
|
||
# `address` is `listOf nonEmptyStr` upstream (multi-bind
|
||
# support). Single-host bind goes through as a one-element list.
|
||
address = [ "0.0.0.0" ];
|
||
# `port` is `listOf port` upstream. Same shape.
|
||
port = [ cfg.httpPort ];
|
||
max_request_size = cfg.maxRequestSize;
|
||
# Federation enabled at the protocol level so swarms
|
||
# can be wired up later by extending `trustedServers`
|
||
# without a homeserver restart. Empty trusted_servers
|
||
# keeps it effectively closed until peers are listed.
|
||
allow_federation = true;
|
||
trusted_servers = cfg.trustedServers;
|
||
# Token-gated registration: hive-c0re holds the token,
|
||
# agents never see it. allow_registration must be true
|
||
# for the token flow to engage; the absent
|
||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||
# keeps the server closed to anyone without the token.
|
||
allow_registration = true;
|
||
registration_token_file = toString cfg.registrationTokenFile;
|
||
# E2EE disabled in initial rollout per operator call
|
||
# (#548) — re-enabling tracked at #551.
|
||
allow_encryption = false;
|
||
};
|
||
};
|
||
environment.systemPackages = [ cfg.package ];
|
||
};
|
||
};
|
||
|
||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||
allowedTCPPorts = [
|
||
cfg.httpPort
|
||
];
|
||
};
|
||
};
|
||
}
|