Matrix and mautrix bridges
How DNF implements the Matrix server (Synapse) and mautrix bridges. For day-to-day operation, see Matrix et les ponts.
Overview
Section titled “Overview”Everything is provided by dnf/modules/service/matrix.nix, with two satellite modules : element.nix (static web client) and turn.nix (coturn relay for audio/video).
- Caddy routes
/_matrix/*and/_synapse/client/*to Synapse and serves the/.well-known/matrix/{client,server}files (client discovery and federation). - Synapse listens on
dnfConfig.network.ports.matrix, with a local PostgreSQL base (created withCcollation by amatrix-db-initunit). - Kanidm provides authentication : OIDC client provisioned via
darkone.service.idm.oauth2.matrix, local part derived frompreferred_username(see Authentication & IDM). - coturn is wired up automatically if the
turnservice is declared (turn_uris, shared secret via sops). It relays calls for two of the legacy clients, the only stack Element Classic knows how to use. - MatrixRTC (
matrixRtc.enable) adds the Element Call stack : a LiveKit SFU and its authorization service, served on the Matrix vhost under/livekit/sfuand/livekit/jwt. The client exchanges an OpenID token from Synapse for a LiveKit JWT, then sends its media directly to the SFU, outside the proxy. Required by Element X, which doesn’t know the other stack.
Module anatomy
Section titled “Module anatomy”matrix.nix is a lib.mkMerge of independent blocks:
| Block | Condition | Content |
|---|---|---|
| Base | always | OIDC client, reverse proxy, persistence |
| Server | matrix.enable | Synapse, PostgreSQL, common secrets, doublepuppet appservice |
| One block per bridge | matrix.enable && bridges.<x>.enable | sops secrets + bridge’s mautrix service |
Each bridge can thus be disabled individually
(darkone.service.matrix.bridges.{whatsapp,signal,telegram,messenger,discord}.enable),
its sops secrets only being declared if the bridge is active.
Mautrix bridges
Section titled “Mautrix bridges”DNF relies on the nixpkgs services.mautrix-* modules, which generate the
appservice registration and register it with Synapse on startup.
Two generations of bridges coexist, with different configuration keys:
| Bridge | Generation | Double puppeting | Level |
|---|---|---|---|
| bridgev2 (Go) | double_puppet.secrets | user | |
| signal | bridgev2 (Go) | double_puppet.secrets | user |
| messenger (meta) | bridgev2 (Go) | double_puppet.secrets | user |
| telegram | legacy (Python) | bridge.login_shared_secret_map | full |
| discord | legacy (Go) | bridge.login_shared_secret_map | user |
Permissions
Section titled “Permissions”The mkBridgePermissions helper (in matrix.nix) builds the same
policy for all bridges:
permissions: "domain.tld": user # tout compte local, avec son propre compte distant "@alice:domain.tld": admin # le matrix.admin déclaré dans etc/config.yamlDistant sessions are isolated per Matrix user: generalizing to all domain accounts carries no risk of interference.
Double puppeting
Section titled “Double puppeting”Official appservice 🡕 method:
a doublepuppet appservice is registered in Synapse
(app_service_config_files) with a namespace covering @.*:domain.tld.
Its as_token allows each bridge to emit events on behalf of the
user’s real account.
The token is unique for all bridges (mautrix-doublepuppet-as-token) and
injected into each config by environment variable substitution
(as_token:$MAUTRIX_DOUBLEPUPPET_AS_TOKEN), as nixpkgs modules pass the
configuration through envsubst with the service’s environmentFile.
Appservice tokens
Section titled “Appservice tokens”The registration of an appservice is the contract between Synapse and the bridge. It contains two tokens, one per authentication direction :
| Token | Direction | Usage |
|---|---|---|
as_token | bridge → Synapse | the bridge authenticates on the client API (sending messages, sync…) |
hs_token | Synapse → bridge | Synapse authenticates when pushing events to the bridge (/transactions) |
By default, nixpkgs modules generate these tokens randomly on
first start and store them in /var/lib/mautrix-*. Consequence :
any reset of the state directory changes the token pair, while
Synapse — which reads registrations only at startup — keeps
the old one in memory ; the bridge is then rejected
(« The as_token was not accepted »).
DNF therefore fixes both tokens of each bridge via sops : the registration becomes a pure function of secrets. Resetting a bridge’s state no longer invalidates anything on Synapse’s side, and the whole setup is reproducible (redeployment, migration, backup restoration).
Federation
Section titled “Federation”Matrix federation is server-to-server communication: an account
@alice:domain.tld talks to @bob:autre.org without an account on the other
server. DNF makes it configurable and explicit via
darkone.service.matrix.federation.
Discovery ≠ connectivity
Section titled “Discovery ≠ connectivity”Two distinct notions, often confused:
- Discovery: appearing in remote directories (room and profile search). DNF
locks it by default
(
allow_public_rooms_over_federation = false, user directory disabled, profiles private on federation). The network remains reachable but not searchable. - Connectivity: the ability to exchange events with another server. This is what federation controls.
Three modes
Section titled “Three modes”The module maps two options to a single Synapse key
federation_domain_whitelist, whose presence changes the meaning:
| Setting | Synapse key | Effect |
|---|---|---|
enable = false | [ ] (empty list) | federation fully blocked |
enable = true; whitelist = [ ] | (absent) | federation open to all |
enable = true; whitelist = [ "ami.org" ] | [ "ami.org" ] | whitelist (inbound + outbound) |
The whitelist is bidirectional: outside the listed domains, neither inbound
nor outbound. This is the recommended setting for a family network. Delegation
(/.well-known/matrix/server → matrix.domain.tld:443) is served by Caddy, so
no dedicated port 8448 is needed.
Accounts : SSO and friends
Section titled “Accounts : SSO and friends”Two account origins coexist on the same server :
- Declared users : authenticated by the Kanidm SSO (OIDC). No Matrix password, no account creation (see Authentication & IDM).
- Friends : local password accounts, opened by registration on
token (
friendRegistration.enable). Without a token, no registration is possible.
Both types share the same rights (bridges included). The token is created on the
host with dnf-mas, or via the Synapse administration API when MAS is disabled
(see
the operations guide).
Above all, both origins draw from the same namespace, and a Matrix
identifier is final : MAS’s users table enforces its uniqueness and a
deactivation never releases it. Declaring a user whose identifier is already
taken by a friend therefore makes their first SSO login fail, as MAS refuses to
attach on its own
(claims_imports.localpart.on_conflict left on fail, otherwise any homonym
would take control of the account). Resolution :
identifier collision.
Next-generation authentication (MAS)
Section titled “Next-generation authentication (MAS)”Optional (darkone.service.matrix.mas.enable), the Matrix Authentication Service 🡕 (MAS) moves all authentication out of Synapse : sign-in, accounts, sessions, the self-service /account portal. It is the foundation of Element X 🡕 (QR code sign-in) and the official path, as Synapse’s internal authentication is end-of-life.
- Single vhost : MAS shares
matrix.domain.tld. Caddy routes the root to it (sign-in pages,/account,/oauth2/*) and the whole compatibility surface/_matrix/client/<version>/{login,logout,refresh}, subpaths included (logout/alland the legacylogin/sso/redirectof pre-OIDC clients, to which Synapse answersM_UNRECOGNIZEDonce delegated) ; Synapse keeps/_matrix/*and/_synapse/*. No extra subdomain or DNS — the MatrixRTC backend attaches the same way, under/livekit/*. - Kanidm remains the identity source : MAS becomes the OIDC client instead of Synapse (same identifier and secret), declared as an upstream provider under a fixed ULID. Synapse only asks MAS to validate each token (shared secret, loopback).
- Automatic discovery : connected to MAS, Synapse itself serves
auth_metadata(MSC2965 🡕). Clients find MAS without modifying the.well-known. - Bridges unchanged : registrations and double puppeting work identically. Only adaptation, automatic : bridges manage their e2ee devices via MSC4190 🡕, since the appservice
/loginAPI no longer exists. - Friends : token-based registration switches from Synapse to MAS (
friendRegistration.enableremains the switch) ; password accounts keep working through the compatibility layer.
Activation and migration : see the operations guide.
Secrets and ports
Section titled “Secrets and ports”- Secrets : declared in blocks in
matrix.nix(sops.secrets.*), and assembled into environment files bysops.templates.*owned by the bridge’s system user. Complete list in the operations page. - Ports :
matrix,matrixTelegramandmatrixDiscordare fixed by the registrydnf/config/network.nix; the default appservice ports of the other bridges (29318, 29319, 29328) are listed there asreservedto avoid any future collision. - MatrixRTC ports : the registry also carries
livekit,livekitJwtand the media boundslivekitRtcUdpStart/End. Two upstream default values are deliberately discarded there : the authorization service leaves 8080 (taken by headscale) and the media range drops below coturn’s, which it overlapped. - WhatsApp statuses :
network.enable_status_broadcast = falsein the bridge config, otherwise the bridge indefinitely recreates the “WhatsApp Status Broadcast” room for each user.
See also
Section titled “See also”- Matrix messaging : user guide : bridge connections for users
- Matrix : operations guide : enable, configure and manage bridges