Three hits the recent write-good.Passive=error flip (hyperhive#4546) now catches on this branch's content: - "stored password is gone" -> "has lost its stored password" - "the ordinary appservice re-login above is unaffected" -> named the actual actor (the admin-sender limitation) instead of leaving it passive - "Federation is enabled" -> "tuwunel enables federation" (tuwunel is already named one clause earlier in the same paragraph) No suppressions, no content dropped.
377 lines
19 KiB
Markdown
377 lines
19 KiB
Markdown
# hive-matrix
|
|
|
|
Private Matrix homeserver (matrix-tuwunel — the conduwuit
|
|
successor) wrapped in a nixos-container, plus optional fluffychat-web
|
|
client at `chat.<swarm-domain>/` (the `gatewayHost` vhost). Configured via
|
|
`services.hyperhive.swarm.matrix.*`; vhost routing lives in
|
|
[`gateway.md`](../networking/gateway.md).
|
|
|
|
## Container shape
|
|
|
|
Same shape as [`gateway.md::hive-forge container shape`](../networking/gateway.md):
|
|
|
|
- Container name `hive-matrix` (not `h-*`) so c0re's lifecycle
|
|
scanner ignores it; operator manages via the standard
|
|
`nixos-container` CLI.
|
|
- Keeps hive-matrix from fighting any `services.matrix-*` the
|
|
operator already runs on the host — separate systemd namespace,
|
|
separate state dir.
|
|
- Container shares the host network namespace
|
|
(`privateNetwork = false`) for state + systemd-unit isolation. Agents
|
|
reach the homeserver at `chat.<swarm-domain>` via the gateway (agents
|
|
run in private netns and can't access host loopback directly).
|
|
- Persistent state at
|
|
`/var/lib/nixos-containers/hive-matrix/var/lib/matrix-tuwunel/`
|
|
survives container restart / host reboot. To wipe, destroy the
|
|
container.
|
|
|
|
## Identity vs API listener: `serverName` vs `gatewayHost`
|
|
|
|
Two distinct hostnames:
|
|
|
|
- **`serverName`** — matrix-spec `server_name`, embedded
|
|
*irrevocably* in every `@user:<server_name>` and `!room:<server_name>`
|
|
identifier minted on this homeserver. You can't change it later
|
|
without abandoning every account and chat history. Defaults to the
|
|
bare `services.hyperhive.swarm.domain`; clients autodiscover the
|
|
actual API endpoint via the `.well-known/matrix/{client,server}`
|
|
routes the gateway serves at that domain.
|
|
- **`gatewayHost`** — the API listener hostname, where the gateway's
|
|
matrix vhost proxies `/_matrix/*` to tuwunel. Defaults to
|
|
`chat.<services.hyperhive.swarm.domain>`. Set to `null` to skip the
|
|
gateway vhost (tuwunel stays direct on `httpPort`).
|
|
|
|
Both default under the **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.
|
|
|
|
⚠️ **they're still not interchangeable, and the difference is the
|
|
cost of changing one.** `gatewayHost` is a routing detail clients
|
|
rediscover through `.well-known`, so it's safe to move on a running
|
|
deployment. The matrix id format bakes `serverName` into every user and
|
|
room id, so adopting a new one does **not** rename the existing users and rooms —
|
|
it strands them, because their ids still name a homeserver that no
|
|
longer answers.
|
|
|
|
### Upgrading a homeserver that already has ids
|
|
|
|
`serverName`'s default has changed across releases. A homeserver that
|
|
has already minted ids under an older default must **pin the value it
|
|
actually minted them under**, not adopt the new default — see above
|
|
for why adopting a new one strands existing users and rooms:
|
|
|
|
```nix
|
|
services.hyperhive.swarm.matrix = {
|
|
# whichever this deployment already uses
|
|
serverName = config.services.hyperhive.domain;
|
|
gatewayHost = "matrix.${config.services.hyperhive.domain}";
|
|
};
|
|
```
|
|
|
|
A rebuild on a host that already has a homeserver prints a
|
|
`hive-matrix: WARNING — … serverName is unset` line when this is missing,
|
|
naming the value it's about to default to. That warning is why this
|
|
section exists; it never fails the rebuild, so it's on you to act on it
|
|
before the homeserver mints the ids.
|
|
|
|
## Default-closed firewall
|
|
|
|
`openFirewall` defaults to `false` (secure-by-default): the host
|
|
reaches the homeserver on loopback, and agent containers reach it
|
|
at `chat.<swarm-domain>` via the gateway — so the firewall hole 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.
|
|
|
|
Federation port 8448 is intentionally not opened here — tuwunel
|
|
serves the federation API on the same `httpPort` as client-server
|
|
by default. Reaching it on 8448 needs either an explicit tuwunel
|
|
bind to that port OR a reverse-proxy + `.well-known/matrix/server`
|
|
delegation (the latter lives in `gateway.md::Discovery flow`).
|
|
|
|
## Provisioning flow (appservice)
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
Registration is closed. The hive's own **appservice** creates accounts:
|
|
hive-c0re holds the appservice token, agents never see
|
|
it, and an agent only ever receives its own `access_token`.
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
The appservice has no URL (`url: null` in its registration), so the
|
|
homeserver never calls out to it and there is no service to run. What the
|
|
registration buys is an identity the homeserver recognises — which is why
|
|
no secret has to be equal on both sides of the wire, and why account
|
|
creation doesn't depend on registration being open to anyone who learns
|
|
a token.
|
|
|
|
1. **System activation** mints a 32-byte random hex appservice token (64
|
|
chars) at `/var/lib/hyperhive/matrix-appservice-token` and its
|
|
spec-required `hs_token` sibling, mode `0600 root:root`, then renders
|
|
the registration to
|
|
`/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`).
|
|
hive-c0re mints the tokens only when missing; the registration is
|
|
re-rendered every time, because the token file can be overwritten in
|
|
place by the swarm secret store and a registration naming a stale
|
|
token authenticates nobody. Runs at activation time, before any
|
|
container start, because the directory is bind-mounted and
|
|
nixos-container refuses to start when a bind source is missing.
|
|
2. **Read-only bind-mount** maps that directory into the tuwunel
|
|
container at the same path.
|
|
3. **systemd `LoadCredential=`** inside the container copies the
|
|
registration into
|
|
`/run/credentials/tuwunel.service/hyperhive-appservice.yaml`, owned by
|
|
tuwunel's dynamic user with mode `0400`, at service start. The host
|
|
file stays `root:root 0600` — no `chown :tuwunel` / `chmod 0640` /
|
|
GID-pin gymnastics required. Keeps `DynamicUser = true` +
|
|
`PrivateUsers = true` intact.
|
|
4. tuwunel's `appservice_dir` points at the credentials directory, not at
|
|
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
|
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
|
the credential id is what makes this work.
|
|
5. **hive-c0re** reads the appservice token and creates each account with
|
|
one `POST /register` typed `m.login.application_service`, persisting
|
|
the returned `access_token` to `<agent-state>/matrix-token`. It never
|
|
mints the token itself: the value has to be the one the rendered
|
|
registration names, and only the nix side writes that.
|
|
6. **An account that exists but has lost its token file** is re-tokened
|
|
by an appservice `POST /login` — no password and no admin rights
|
|
involved. A stored-password login and an admin-room password reset
|
|
remain behind that, for accounts created before the appservice existed
|
|
or named outside its namespace.
|
|
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
|
immediately after writing the token so the daemon picks up the
|
|
new credential without waiting for a full container restart. If
|
|
the restart fails (for example daemon not yet running on first boot)
|
|
hive-c0re logs the error as a warning and the `.path`-trigger sibling
|
|
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
|
brings the daemon up on the same boot cycle anyway.
|
|
|
|
### The appservice's sender account, and why it isn't an admin
|
|
|
|
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
|
|
the homeserver creates itself when it loads the registration — on a
|
|
zero-user database, inside startup, before the HTTP listener accepts
|
|
anything. It's an **ordinary account**: nothing promotes it, and the
|
|
homeserver runs no `admin_execute` for it.
|
|
|
|
Its access token is the **sender token**, and it's the credential
|
|
hive-c0re presents for every homeserver call it makes on the hive's
|
|
behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix`
|
|
container and publishes it to `swarm/services/matrix/sender-token`; the
|
|
hive reads it from there. The name says what it authenticates as — an
|
|
account the appservice registration brings into being — rather than any
|
|
privilege level, because it carries none.
|
|
|
|
It needs no promotion for what the hive does with it. Creating the hive
|
|
Space and the chat room, writing their hierarchy and join rules, and
|
|
inviting agents into them are all ordinary client calls that ride on
|
|
being the rooms' own creator at power level 100 — there is no homeserver
|
|
admin in any of it, and no Synapse admin API to reach for either, since
|
|
tuwunel has none.
|
|
|
|
Two operations need an admin **sender**: `hivectl matrix promote-user`
|
|
and `hivectl matrix reset-password`. Both are `!admin …` messages into
|
|
`#admins:<server_name>`, and tuwunel only treats a message as a command
|
|
when its sender is already an admin. They're swarm-level operations,
|
|
rehomed to the swarm tier rather than granted here; from the hive,
|
|
`@hive:` has no admin sender to make that call with, so both get the
|
|
admin room's refusal rather than an over-privileged credential that
|
|
every other call site would also carry. The one hive-side path that
|
|
depends on them is the automatic password recovery for an agent that
|
|
has lost its stored password — the admin-sender limitation doesn't
|
|
touch the ordinary appservice re-login above.
|
|
|
|
<details><summary>Upgrading a hive that used the registration token</summary>
|
|
|
|
Nothing to do, and nothing to time. The activation script mints the
|
|
appservice token and renders the registration before the homeserver
|
|
restarts, so the first boot after the switch already has both halves.
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
- **Existing accounts keep working.** An access token lives on the
|
|
device that minted it; removing the registration token touches no
|
|
device, no account and no session. `login_with_password` stays on, so
|
|
the password fallback is still there too.
|
|
- **The per-agent sweep honours existing token files.** It skips any
|
|
agent that already has a `matrix-token`, so it re-registers no account
|
|
and displaces no session.
|
|
- **The sender account may already be an admin** on such a hive (it won the
|
|
first-user grant when the hive was new). Nothing here demotes it; the
|
|
homeserver no longer promotes it, so a hive built fresh has an
|
|
ordinary account and an older one keeps whatever standing it acquired.
|
|
- **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by
|
|
nothing. Delete it or leave it; neither does any harm.
|
|
- **`registrationTokenFile` is a removed option.** A config that still
|
|
sets it fails to evaluate with a message naming the appservice — a hive
|
|
that never set it (the default) is unaffected.
|
|
- **A swarm store holding the old `matrix/registration-token` path** is
|
|
no longer read at all. The value that matters now lives at
|
|
`matrix/appservice-token`, and `swarm-secret-publish` on the authelia
|
|
host mints and `put`s it there — the hive uses its locally minted
|
|
token only until the first successful read. See
|
|
[`../swarm/secrets.md`](../swarm/secrets.md) for how that mint stays
|
|
idempotent across runs.
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
</details>
|
|
|
|
Initial rollout settings:
|
|
|
|
- `allow_federation = true` at the protocol level so swarms can be
|
|
wired up later by extending `trustedServers` without a homeserver
|
|
restart. `trusted_servers = []` keeps it effectively closed
|
|
until you list peers.
|
|
- `allow_registration = false`. tuwunel checks this flag only for
|
|
requests that arrive **without** an appservice token, so hive-c0re
|
|
provisions exactly as before and tuwunel refuses everyone else. It's not a
|
|
hardening afterthought: with no registration token configured,
|
|
`allow_registration = true` makes tuwunel refuse to start unless
|
|
`yes_i_am_very_very_sure_…_open_registration_…` is also set.
|
|
- `allow_encryption` — server-side E2EE switch, sourced from
|
|
`services.hyperhive.swarm.matrix.allowEncryption` (**default `false`**, opt-in).
|
|
Off by default because on the hive-internal homeserver the operator
|
|
already controls the transport; turn it on for encrypted rooms on
|
|
external / federated homeservers or to keep contents opaque to the
|
|
homeserver admin. **The agent matrix client always supports decryption
|
|
regardless of this flag** — it uses the `e2e-encryption` feature of
|
|
`matrix-sdk` so it can read encrypted rooms it's invited to even when
|
|
this homeserver doesn't permit room encryption. matrix-sdk stores
|
|
crypto keys in the per-agent sqlite store under the state dir; they persist across
|
|
restarts (lost on `--purge`). `read_room` decrypts via
|
|
`room.messages()` — UTD events surface as `event_type =
|
|
"m.room.encrypted"` with `body = "[unable to decrypt]"`.
|
|
Cross-signing and automatic key backup aren't enabled for the first
|
|
pass: static bearer-token bot accounts can't bootstrap cross-signing
|
|
without MSC3967.
|
|
|
|
## Hive Matrix Space
|
|
|
|
On first boot, after hive-c0re provisions all agent accounts, it
|
|
creates a private **Matrix Space** named `"hive"` using the `@hive:`
|
|
account (`@hive:<server_name>`) and invites every provisioned agent
|
|
into it. This gives the operator a single Space in FluffyChat or any
|
|
Matrix client that groups all agent-to-agent + operator rooms in one
|
|
place.
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
The sweep also provisions a default **`hive-chat` room** as an
|
|
`m.space.child` of the Space. Joining a Space doesn't autojoin
|
|
child rooms — the explicit room entry ensures the operator and every
|
|
agent can find a common chat room without manual setup. Room join is
|
|
restricted (any Space member including the operator can join; agents
|
|
are explicitly invited). Room version pinned to 10 for the restricted
|
|
join floor.
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
**State**: hive-c0re persists both room IDs to `/var/lib/hyperhive/matrix/`
|
|
(mode `0600`, owned by the hive-c0re service user):
|
|
- `space-room-id` — the Space itself
|
|
- `chat-room-id` — the `hive-chat` room
|
|
|
|
These paths are **outside** every agent state dir and **aren't** deleted by
|
|
`nixos-container destroy --purge` — both survive full agent purges, and
|
|
hive-c0re reuses them on re-provision.
|
|
|
|
**Idempotent**: if the files exist and are non-empty, hive-c0re considers the Space and
|
|
room already created. Delete the files to force
|
|
re-creation (for example after a homeserver wipe).
|
|
|
|
## Configuration tuning
|
|
|
|
```nix
|
|
services.hyperhive.swarm.matrix = {
|
|
trustedServers = [ "matrix.org" "example.com" ]; # default: []
|
|
maxRequestSize = 20000000; # default: 20 MB
|
|
};
|
|
```
|
|
|
|
**`trustedServers`** (default `[]`) — list of peer homeserver names
|
|
whose signing keys tuwunel fetches and trusts. tuwunel enables
|
|
federation at the protocol level from first boot (`allow_federation =
|
|
true`) but trusts no remote homeserver until you list it here. For a closed
|
|
single-hive deployment the default empty list is correct — add peer
|
|
hive domains here when connecting hives into a swarm (see
|
|
[`docs/swarm/`](../swarm/README.md)).
|
|
|
|
**`maxRequestSize`** (default `20_000_000` bytes = 20 MB) — maximum
|
|
size of a single matrix client request body. Matches the matrix-spec
|
|
recommendation for media uploads and the upstream tuwunel default.
|
|
Raise for deployments that need large file transfers; lower for
|
|
resource-constrained hosts where a 20 MB request is unexpectedly large.
|
|
|
|
## Assertion rationale
|
|
|
|
`config.assertions` in this module fail eval early rather than ship
|
|
surprising behaviour:
|
|
|
|
- **`services.hyperhive.swarm.matrix.gatewayHost != ""`** — same footgun as `forge.domain`:
|
|
empty string renders `.<hive>`-shaped garbage in both nginx
|
|
`server_name` (treated as wildcard catch-all, surprising) and
|
|
`/etc/hosts` (invalid entry). `null` is the right opt-out shape;
|
|
a config assertion rejects empty string explicitly.
|
|
SSO is unconditional, so the three below are requirements of running a
|
|
homeserver at all rather than of a setting:
|
|
|
|
- **Set `sso.clientSecretFile`** — fails at eval, not at boot:
|
|
tuwunel reads its identity providers from the config file, so a
|
|
half-configured one can stop the homeserver from starting outright
|
|
rather than merely hiding a login button. On a host that also runs
|
|
the swarm's authelia it's wired up for you.
|
|
- **Set `swarm.authelia.url`** — without a provider URL there
|
|
is nothing to discover against.
|
|
- **Set `gatewayHost != null`** — the SSO callback URL is
|
|
format-locked to `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`,
|
|
and the identity provider needs a public name to redirect the
|
|
browser to.
|
|
|
|
`server_name`'s own bogus-value guard lives in `hive-network.nix`
|
|
(`services.hyperhive.domain != null`), not here — see
|
|
[`docs/networking/network.md`](../networking/network.md).
|
|
|
|
## fluffychat-web build fixes
|
|
|
|
`pkgs.fluffychat-web` ships from `flutter341.buildFlutterApplication`,
|
|
which has two upstream gaps for fluffychat's web target:
|
|
|
|
- The dart web-worker entry point (`web/native_executor.dart`) isn't
|
|
compiled — `buildFlutterApplication` only runs `flutter build web`
|
|
on the main entry.
|
|
- `native_imaging`'s C source isn't built — emscripten isn't a
|
|
flutter-builder native build input.
|
|
|
|
Both fixed in `nix/host-modules/hive-matrix.nix` via two derivations:
|
|
|
|
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
|
|
`native_imaging` C source via `pkgs.emscripten`. Source comes
|
|
from `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging`
|
|
— already in the build closure of the flutter app, so no parallel
|
|
hash pin and version autosyncs with nixpkgs bumps. Build closure
|
|
is ~3.6 GiB (emscripten LLVM); runtime closure is just the two
|
|
output files. `dontConfigure = true` because cmake runs inside
|
|
`js/Makefile` via `emcmake cmake`, not at the package root. The
|
|
build script needs `HOME` + `EM_CACHE` writable for emscripten's
|
|
on-demand sysroot build (libc, libc++ → wasm).
|
|
- **`fluffychat-web-fixed`** is `pkgs.fluffychat-web` plus a
|
|
`postInstall` patch that (a) compiles `web/native_executor.dart`
|
|
via `dart compile js` (dart from the flutter341 closure, no
|
|
incremental cost) and (b) installs `fluffychat-web-imaging`'s
|
|
outputs into `$out`.
|
|
|
|
Two subtle details worth knowing before touching either derivation:
|
|
|
|
- **`make -C js`** instead of `cd js; make` — keeps the build-phase
|
|
pwd at the source root so `installPhase` doesn't have to know
|
|
about the cd. Robust against future reorders / `dontBuild`.
|
|
- **`web/native_executor.dart`** as a build-CWD-relative path,
|
|
*not* `$src/web/...` — `dart`'s `package_config.json` walk-up
|
|
needs to hit `buildFlutterApplication`'s pub-get output
|
|
(`.dart_tool/` in the build CWD). Walking up from a read-only
|
|
`$src/` store path finds no `.dart_tool/` and errors with
|
|
"Couldn't resolve the package 'matrix'."
|
|
|
|
Drop both derivations when nixpkgs's flutter builder grows worker
|
|
+ emcc support upstream.
|
|
|
|
Mount point is `chat.<swarm-domain>/` (the `gatewayHost` vhost);
|
|
upstream `--base-href "/"` is correct at sub-domain root, no override.
|