Skip to content

Matrix and messaging bridges

Enable the Matrix server (Synapse), its Element web client and mautrix bridges (WhatsApp, Signal, Telegram, Messenger, Discord). For the architecture, see Matrix in DNF.

  1. Declare the services on the target host in etc/config.yaml :

    etc/config.yaml
    services:
    matrix:
    title: "Matrix"
    description: "Instant messaging"
    element: # web client
    turn: # audio/video relay for legacy clients
  2. Declare the Matrix administrators in the network section (outside hosts) :

    etc/config.yaml
    network:
    matrix:
    admins: ["alice", "bob"] # local parts of the administrators

    A single list for both administrations, applied at rebuild : bridges and server. These accounts also receive monitoring alerts.

  3. Create the required secrets (see table below), then deploy.

Authentication is handled via SSO Kanidm (OIDC) : any declared user logs in with their network account, and no Matrix account creation is needed.

All internal : just configure-admin-host creates them in usr/secrets/secrets.yaml, without ever overwriting existing entries.

Sops keyPurpose
oidc-secret-matrixKanidm OIDC client secret (see SSO)
turn-secretcoturn shared secret (if turn is enabled)
livekit-secretMatrixRTC shared secret (if matrixRtc is enabled)
mautrix-doublepuppet-as-tokenDouble puppeting appservice token (shared across bridges)
mautrix-doublepuppet-hs-tokenSame, required by Synapse, never used

All authentication is delegated to the Matrix Authentication Service 🡕 (MAS), with no alternative : it is the missing piece that Synapse needs for Element X 🡕, QR code login and the account portal, while Synapse’s built-in authentication is being retired upstream (MSC3861). Kanidm remains the identity provider : MAS becomes its OIDC client in place of Synapse.

For MAS’s place in the architecture, see Matrix in DNF.

TopicWhere it happens
SSO loginMAS, Kanidm OIDC client
Account portalhttps://matrix.domain.tld/account/
Registration tokensdnf-mas on the host
Server administratorsnetwork.matrix.admins, declarative
Accounts, sessions, roomsKetesa interface
  1. Generate the three dedicated secrets (idempotent, never overwrites existing ones) :

    Fenêtre de terminal
    just configure-admin-host
    Sops keyRole
    mas-encryption-secretMAS internal encryption
    mas-synapse-secretMAS ↔ Synapse shared secret
    mas-rsa-private-keyToken signing key
  2. Deploy. On a new server, that’s it. On a server that already has accounts, continue with the migration without letting anyone log in between the two steps.

MAS becomes the owner of accounts and sessions : they must be imported with the official syn2mas tool, served by the host’s dnf-mas command. Without this import, all accounts are invisible and everyone is logged out.

  1. Back up, in this order (the dump must fit within the snapshot) :

    Fenêtre de terminal
    sudo systemctl start postgresqlBackup.service
    sudo systemctl start restic-backups-system-main.service
  2. Deploy the configuration (see above). The maintenance window starts here.

  3. Stop both services : syn2mas requires Synapse to be offline, and MAS must not provision any accounts in the meantime.

    Fenêtre de terminal
    sudo systemctl stop matrix-synapse matrix-authentication-service
  4. Verify, then do a dry run, then migrate :

    Fenêtre de terminal
    sudo dnf-mas syn2mas check
    sudo dnf-mas syn2mas migrate --dry-run
    sudo dnf-mas syn2mas migrate

    The report lists accounts, SSO links, access tokens and devices carried over. The hundreds of “ignored” users are the bridges’ ghosts : that’s expected.

  5. Restart, MAS first :

    Fenêtre de terminal
    sudo systemctl start matrix-authentication-service
    sudo systemctl start matrix-synapse

In addition to SSO users, you can open token-based registration for local password accounts (friends). Without a token, no registration is possible.

# usr/machines/<host>/configuration.nix
darkone.service.matrix.friendRegistration.enable = true;
  1. Create a token on the Matrix host. Here it is valid once and for 7 days (without options, the token is single-use and never expires) :

    Fenêtre de terminal
    sudo dnf-mas manage issue-user-registration-token --usage-limit 1 --expires-in 604800

    The response displays the token, for example Created user registration token: 9SwPTGIFGQPa. Send it to the friend.

  2. The friend opens https://matrix.domain.tld/register and chooses password-based creation.

  3. They enter their username and password, then the token, requested at the next step.

  4. They then sign in with this username / password pair from any client, Element X included.

Friends and SSO users share a single namespace : @bob:domain.tld belongs to only one account, regardless of its origin. And a Matrix identifier is permanent — the users table of MAS enforces its uniqueness, and deactivating an account does not free its identifier. No renaming, no recycling.

The typical case : a friend registers as bob, then bob is declared later as a DNF user in etc/config.yaml. Their first SSO connection then stops on a User exists error page :

Upstream account provider returned "bob" as username, which is not linked to
that upstream account. Your homeserver does not allow linking an upstream
account to an existing account

Check before declaring a user, on the Matrix host :

Fenêtre de terminal
sudo -u postgres psql matrix-authentication-service \
-c "SELECT username FROM users WHERE lower(username) = lower('bob');"

Two outcomes, depending on what you want to keep :

Links the Kanidm identity to the existing friend account, which keeps everything : rooms, history, encryption keys, bridges. This is almost always what you want, and the person concerned does it themselves.

  1. First declare bob in etc/config.yaml and deploy, so that they get Kanidm credentials.

  2. Bob signs in on https://matrix.domain.tld/account/ with their friend password.

  3. In the same browser, they open the link account of the IDM provider :

    https://matrix.domain.tld/upstream/authorize/01JDNF0000000000000KAN1DM0
  4. They authenticate on Kanidm ; MAS then offers ” Link to your existing account ”, which they confirm.

Bob then signs in through SSO like any declared user. Their friend password stays valid : deleting it is done from the account portal.

The WhatsApp, Signal, Telegram and Messenger bridges are enabled by default with Matrix ; Discord is disabled by default. Set per host in the Nix configuration of the machine :

# usr/machines/<host>/configuration.nix
darkone.service.matrix.bridges = {
discord.enable = true; # opt-in
messenger.enable = false; # disable a bridge that is on by default
};

Each bridge has its own secrets :

Bridgesops keysNote
WhatsAppmautrix-whatsapp-as-token, mautrix-whatsapp-hs-token, mautrix-whatsapp-encryption-pickle-keyopenssl rand -hex 32 each
Signalmautrix-signal-as-token, mautrix-signal-hs-token, mautrix-signal-encryption-pickle-keyopenssl rand -hex 32 each
Messengermautrix-meta-as-token, mautrix-meta-hs-token, mautrix-meta-encryption-pickle-keyopenssl rand -hex 32 each
Telegrammautrix-telegram-api-id, mautrix-telegram-api-hash, mautrix-telegram-as-token, mautrix-telegram-hs-tokenAPI id/hash to create on my.telegram.org 🡕
Discordmautrix-discord-as-token, mautrix-discord-hs-tokenopenssl rand -hex 32 each

The as/hs tokens make each bridge’s appservice registration deterministic (see appservice tokens).

Nothing to manage : every local account (@*:domain.tld) can use each bridge with its own remote account, with no interference between users. Accounts listed in network.matrix.admins additionally have bot administration commands (help in a direct conversation with the bot for the list).

Bridges share a doublepuppet appservice (token mautrix-doublepuppet-as-token) : messages sent from the phone appear in Matrix as coming from the user’s real account. This is automatic for all local accounts, no action per user.

Two stacks coexist, because neither covers every client. They are activated separately and do not replace each other :

Legacy WebRTCMatrixRTC
Optiondarkone.service.turn.enabledarkone.service.matrix.matrixRtc.enable
ComponentscoturnLiveKit (SFU) + authorization service
Two-person callsyesyes
Group callsnoyes
Element Classic (Android)yesno
Element Web / Desktopyesyes
Element X (Android, iOS)noyes

Keep both enabled : Element Classic only speaks the first, Element X only the second.

  1. Enable the MatrixRTC stack in the host’s Nix config :

    # usr/machines/<host>/configuration.nix
    darkone.service.matrix.matrixRtc.enable = true; # required by Element X
  2. Generate the shared secret, then deploy :

    Fenêtre de terminal
    just configure-admin-host
    just apply <host> switch
  3. Check that the server announces a transport :

    Fenêtre de terminal
    # must contain "org.matrix.msc4143.rtc_foci"
    curl -A Mozilla/5.0 https://domain.tld/.well-known/matrix/client
    # org.matrix.msc4143 must be true
    curl -A Mozilla/5.0 https://matrix.domain.tld/_matrix/client/versions

No extra subdomain or certificate : the SFU and its authorization service are served on the Matrix vhost, under /livekit/sfu and /livekit/jwt. Nothing to configure client-side either — Element X and Element Web embed Element Call and discover the transport on their own.

Media does not go through the reverse proxy: clients reach the host directly. These ports must therefore be opened up to it, and forwarded if it sits behind NAT:

PortStackUsage
30000-30100/udpMatrixRTCWebRTC media, one port per participant
7881/tcpMatrixRTCFallback for networks that block UDP
3478/udp, 3478/tcp, 5349/tcpcoturnSTUN and TURN negotiation
61000-65535/udpcoturnRelayed media

A WebRTC client discovers its public address with a STUN Binding request, which it always sends without authentication, including to the TURN server. If coturn rejects it (the secure-stun option), the client gets no server-reflexive candidates: all it has left are its local addresses, unusable outside the local network, and its relay address. No direct path is ever attempted then: all calls traverse coturn, slower to establish and with no fallback when the relay saturates.

DNF therefore leaves secure-stun disabled. TURN allocations, for their part, remain authenticated through the shared secret (use-auth-secret): only the Binding is anonymous.

To check from an external machine:

Fenêtre de terminal
nix shell nixpkgs#stuntman --command stunclient turn.domain.tld 3478

Binding test: success followed by the address as seen by the server: correct. Binding test: fail: anonymous requests are rejected, every call will be relayed.

During a call, coturn logs errors of this form :

ERROR session 000000000000000138: A peer IP 192.168.0.42 denied in the range:
192.168.0.0-192.168.255.255 in server 0

This is not a failure. denied-peer-ip filters the destination of an allocation (CreatePermission and ChannelBind), not the clients allowed to connect. The client proposed a candidate with a private address, coturn refused to relay to it, ICE discards the pair and continues with the public candidates.

DNF therefore refuses all private ranges and only allows the host’s public address, because coturn is a public-only relay : clients reach it on this address, and relay-ip is the same. Two reasons not to reopen 10.0.0.0/8 or 100.64.0.0/10.

Asymmetric path. The relayed media leaves through the public relay-ip while a peer on the tailnet or local network is only reachable through the other interface. The call establishes, then dies when the connection tracking entry expires : this is the signature of long calls that cut off without reason. The mobility option makes the case worse, a phone that changes network keeps a permission pointing to the address it just left.

Pivot toward the private network. A relay that forwards to RFC1918 or CGNAT becomes an entry point for anyone holding TURN credentials, that is any account of the Matrix server since Synapse distributes them (use-auth-secret).

By default, the server federates with all Matrix servers, while staying invisible in search results (directories not exposed). To control the scope, set darkone.service.matrix.federation in the host’s Nix config :

# usr/machines/<host>/configuration.nix
darkone.service.matrix.federation = {
enable = true; # false = federation totally blocked
whitelist = [ ]; # empty = open ; otherwise a whitelist
};
GoalSetting
Isolated network (no federation)enable = false;
Open to the whole Matrix ecosystemenable = true; whitelist = [ ];
Restricted to trusted serversenable = true; whitelist = [ "ami.org" "matrix.org" ];

Check after deployment :

  1. Test incoming federation with federationtester.matrix.org 🡕 on domain.tld → all indicators green.

  2. With a whitelist, a non-listed server is refused (Synapse log : Federation denied), a listed domain works.

A public room in the Matrix sense can be joined freely: no validation. For a room that is “joinable but on approval”, use the knock access rule — a per-room setting, on the client side, no server configuration needed.

  1. In Element: room → Room Settings → Security & Privacy.

  2. Room access → “Ask to join” (knock).

  3. A user (local or federated remote) requests access; a moderator approves or denies.

ActionCommande
État des servicessystemctl status matrix-synapse mautrix-whatsapp mautrix-signal mautrix-telegram mautrix-meta-messenger
Journaux d’un pontjournalctl -u mautrix-whatsapp -f
Journaux Synapsejournalctl -u matrix-synapse -f
Journaux MASjournalctl -u matrix-authentication-service -f
Journaux MatrixRTCjournalctl -u livekit -u lk-jwt-service -f

dnf-mas wraps the official mas-cli tool on the Matrix host : it reconstructs the service configuration and secrets, so it works whether the service is stopped or running. Always run with sudo.

ActionCommand
Create a registration tokensudo dnf-mas manage issue-user-registration-token
List administratorssudo dnf-mas manage list-admin-users
Remove a legacy promotionsudo dnf-mas manage demote-admin alice
Create an account (bot, service)sudo dnf-mas manage register-user
Change a passwordsudo dnf-mas manage set-password alice
Kill all sessions for an accountsudo dnf-mas manage kill-sessions alice
Lock an accountsudo dnf-mas manage lock-user alice
Full diagnosticssudo dnf-mas doctor

mas-cli exposes no read commands : to view accounts, their devices and sessions, enable the matrix-admin service, the Ketesa 🡕 web interface (formerly synapse-admin), which speaks to the admin APIs of both MAS and Synapse.

# etc/config.yaml, on the Matrix server host
services:
matrix-admin:

It is served at https://matrix-admin.domain.tld, from the LAN and tailnet only. The subdomain must still resolve publicly to the reverse proxy : Caddy needs it for the ACME certificate before rejecting external requesters. Log in via the OIDC button with an account listed in network.matrix.admins : the MAS policy grants it the urn:mas:admin and urn:synapse:admin:* scopes that the interface needs on both APIs.

SideWhat the interface allows
MASSessions (compat, OAuth2, personal), emails, upstream OIDC links, registration tokens, password, lockout, deactivation
SynapseAccounts and devices, rooms, media, reports, rate limits

After a migration, sessions that are already open keep working: a broken connection therefore only shows up on the first fresh login. Watch MAS during an attempt:

Fenêtre de terminal
journalctl -u matrix-authentication-service -f
MessageCauseFix
wrong signature alg on /upstream/callback/…MAS expects an RS256 id_token, Kanidm signs with ES256id_token_signed_response_alg of the upstream provider: set to ES256 by the module
client registration denied by the policy: invalid redirect_uriThe client_uri advertised by the client does not match its redirect schemeDo not force oidc_metadata in the Element config; its defaults pass
M_UNRECOGNIZED on /login/sso/redirectThe path goes to Synapse instead of MASCheck the @masCompat matcher of the Caddy vhost
Page User exists: ‘returned “bob” as username, which is not linked’Another account already occupies the declared user’s identifierMerge or rename (see identifier collision)
“The QR code is not supported” on the client sideThe MSC4108 rendezvous channel is missingexperimental_features.msc4108_enabled: enabled by the module along with MAS
“The call is not supported”, MISSING_MATRIX_RTC_TRANSPORTNo MatrixRTC backend advertisedEnable matrixRtc.enable (see Audio and video calls)
Call that rings but stays silent, or drops after a few secondsMedia ports do not reach the hostOpen 30000-30100/udp and 7881/tcp up to it
Call slow to establish, or failing outright, with all traffic going through coturnClients get no server-reflexive candidates, hence no direct pathcoturn must answer anonymous STUN Binding requests (see Permanently relayed calls)

Quick checks, without logging in:

Fenêtre de terminal
# org.matrix.msc4108 doit valoir true
curl -A Mozilla/5.0 https://matrix.domain.tld/_matrix/client/versions
# doit répondre 201 et une url publique
curl -A Mozilla/5.0 -X POST --data probe \
https://matrix.domain.tld/_matrix/client/unstable/org.matrix.msc4108/rendezvous

A bot that does not reply to help while its service is running almost always falls into one of two cases : its tokens no longer match the registration loaded by Synapse, or it receives messages but cannot decrypt them. Diagnose in order :

  1. Is the service actually running ?

    Fenêtre de terminal
    systemctl status mautrix-<bridge>
    journalctl -u mautrix-<bridge> -n 50

    If it keeps restarting with « The as_token was not accepted », Synapse’s in-memory registration no longer matches the bridge’s tokens (typical after a reset or migration, see the box above) : systemctl restart matrix-synapse, then restart the bridge.

  2. Does the message reach the bridge ? Follow Synapse while sending a help to the bot :

    Fenêtre de terminal
    journalctl -u matrix-synapse -f | grep transactions

    A PUT .../_matrix/app/v1/transactions/... 200 line should appear. If nothing goes out, the appservice is not loaded : check for the registration file in /var/lib/mautrix-<bridge>/ and restart Synapse.

  3. Can the bridge decrypt it ? If the transaction goes through (200) but the bot stays silent, follow its logs while sending a new message :

    Fenêtre de terminal
    journalctl -u mautrix-<bridge> -f

    Look for decrypt, session, verification, dropping. A decryption failure means the room’s session key has not been shared with the bot’s current device (typical after a bridge reset : its e2ee identity has changed).

  4. Recreate the conversation. Leave the direct message with the bot and start a new one : the client establishes a fresh encryption session and shares the key with the bot’s current device. Also check that your own Element session is verified.

  5. Last resort : reset the bridge state (box below), then restart Synapse to reload the registration.