Skip to content

Matrix and mautrix bridges

How DNF implements the Matrix server (Synapse) and mautrix bridges. For day-to-day operation, see Matrix et les ponts.

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).

Diagram
  • 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 with C collation by a matrix-db-init unit).
  • Kanidm provides authentication : OIDC client provisioned via darkone.service.idm.oauth2.matrix, local part derived from preferred_username (see Authentication & IDM).
  • coturn is wired up automatically if the turn service 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/sfu and /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.

matrix.nix is a lib.mkMerge of independent blocks:

BlockConditionContent
BasealwaysOIDC client, reverse proxy, persistence
Servermatrix.enableSynapse, PostgreSQL, common secrets, doublepuppet appservice
One block per bridgematrix.enable && bridges.<x>.enablesops 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.

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:

BridgeGenerationDouble puppetingLevel
whatsappbridgev2 (Go)double_puppet.secretsuser
signalbridgev2 (Go)double_puppet.secretsuser
messenger (meta)bridgev2 (Go)double_puppet.secretsuser
telegramlegacy (Python)bridge.login_shared_secret_mapfull
discordlegacy (Go)bridge.login_shared_secret_mapuser

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.yaml

Distant sessions are isolated per Matrix user: generalizing to all domain accounts carries no risk of interference.

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.

Diagram

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.

The registration of an appservice is the contract between Synapse and the bridge. It contains two tokens, one per authentication direction :

TokenDirectionUsage
as_tokenbridge → Synapsethe bridge authenticates on the client API (sending messages, sync…)
hs_tokenSynapse → bridgeSynapse 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).

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.

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.

The module maps two options to a single Synapse key federation_domain_whitelist, whose presence changes the meaning:

SettingSynapse keyEffect
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/servermatrix.domain.tld:443) is served by Caddy, so no dedicated port 8448 is needed.

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.

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.

Diagram
  • 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/all and the legacy login/sso/redirect of pre-OIDC clients, to which Synapse answers M_UNRECOGNIZED once 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 /login API no longer exists.
  • Friends : token-based registration switches from Synapse to MAS (friendRegistration.enable remains the switch) ; password accounts keep working through the compatibility layer.

Activation and migration : see the operations guide.

  • Secrets : declared in blocks in matrix.nix (sops.secrets.*), and assembled into environment files by sops.templates.* owned by the bridge’s system user. Complete list in the operations page.
  • Ports : matrix, matrixTelegram and matrixDiscord are fixed by the registry dnf/config/network.nix ; the default appservice ports of the other bridges (29318, 29319, 29328) are listed there as reserved to avoid any future collision.
  • MatrixRTC ports : the registry also carries livekit, livekitJwt and the media bounds livekitRtcUdpStart/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 = false in the bridge config, otherwise the bridge indefinitely recreates the “WhatsApp Status Broadcast” room for each user.