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 handled by dnf/modules/service/matrix.nix and its submodules matrix/, 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, uses a local PostgreSQL database (created with C collation by a matrix-db-init unit).
  • Kanidm provides authentication: an OIDC client is provisioned via darkone.service.idm.oauth2.matrix, with the local part derived from the preferred_username (see Authentication & IDM).
  • coturn is automatically connected if the turn service is declared (turn_uris, shared secret via sops). It relays calls for two legacy clients, the only stack supported by Element Classic.
  • MatrixRTC (matrixRtc.enable) adds the Element Call stack: a LiveKit SFU and its authorization service, exposed on the Matrix vhost at /livekit/sfu and /livekit/jwt. The client exchanges a Synapse OpenID token for a LiveKit JWT, then sends its media directly to the SFU, bypassing the proxy. This is required by Element X, which does not support the legacy stack.

Independent blocks, spread across submodules:

FileConditionContent
matrix.nixalwaysOIDC client, reverse proxy, persistence
matrix.nixmatrix.enableSynapse, PostgreSQL, federation
matrix/mas.nixmatrix.enableMAS, dnf-mas tool
matrix/rtc.nixmatrix.enable && matrixRtc.enableLiveKit, JWT service
matrix/bridges.nixmatrix.enabledoublepuppet appservice
matrix/bridges.nixmatrix.enable && bridges.<x>.enablesops secrets + bridge service

Each bridge can therefore be disabled individually (darkone.service.matrix.bridges.{whatsapp,signal,telegram,messenger,discord}.enable), its sops secrets being declared only 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/bridges.nix) builds the same policy for all bridges:

permissions:
"domain.tld": user # any local account, with its own remote account
"@alice:domain.tld": admin # each network.matrix.admins of etc/config.yaml

Remote sessions are isolated per Matrix user: extending it to every account in the domain 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/server → matrix.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.

Always active, the Matrix Authentication Service 🡕 (MAS) offloads all authentication from Synapse: login, accounts, sessions, self-service portal /account. It is the foundation for Element X 🡕 (QR code login) and the official path forward, as Synapse’s built-in authentication is end-of-life.

Diagram
  • Single vhost: MAS shares matrix.domain.tld. Caddy routes it the root (login pages, /account, /oauth2/*) and the entire compatibility surface /_matrix/client/<version>/{login,logout,refresh}, including sub-paths (logout/all and the legacy login/sso/redirect for pre-OIDC clients, to which Synapse responds M_UNRECOGNIZED once delegated); Synapse keeps /_matrix/* and /_synapse/*. No additional subdomains or DNS — the MatrixRTC backend grafts itself the same way, under /livekit/*.
  • Kanidm remains the identity source: MAS becomes the OIDC client in place of Synapse (same client ID and secret), declared as the upstream provider under a fixed ULID. Synapse now only asks MAS to validate every token (shared secret, loopback).
  • Automatic discovery: connected to MAS, Synapse itself serves auth_metadata (MSC2965 🡕). Clients find MAS without any .well-known changes.
  • Bridges unchanged: registrations and double puppeting work identically. The only adaptation, automatic: bridges handle their e2ee devices via MSC4190 🡕, as the appservice /login API no longer exists.
  • Friends: token-based registration moves 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 per block in each submodule (sops.secrets.*), then assembled into environment files by sops.templates.* owned by the bridge’s system user. Full list in the operations page.
  • Ports: matrix, matrixTelegram and matrixDiscord are fixed by the dnf/config/network.nix registry; the other bridges’ default appservice ports (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: the authorization service leaves 8080 (taken by headscale) and the media range moves below coturn’s, which it overlapped.
  • WhatsApp statuses: network.enable_status_broadcast = false in the bridge config, otherwise the bridge endlessly recreates the « WhatsApp Status Broadcast » room for every user.