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.
Enable Matrix
Section titled “Enable Matrix”-
Declare the services on the target host in
etc/config.yaml:etc/config.yaml services:matrix:title: "Matrix"description: "Instant messaging"element: # web clientturn: # audio/video relay for legacy clients -
Declare the Matrix administrators in the
networksection (outside hosts) :etc/config.yaml network:matrix:admins: ["alice", "bob"] # local parts of the administratorsA single list for both administrations, applied at rebuild : bridges and server. These accounts also receive monitoring alerts.
-
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.
Required secrets
Section titled “Required secrets”All internal : just configure-admin-host creates them in
usr/secrets/secrets.yaml, without ever overwriting existing entries.
| Sops key | Purpose |
|---|---|
oidc-secret-matrix | Kanidm OIDC client secret (see SSO) |
turn-secret | coturn shared secret (if turn is enabled) |
livekit-secret | MatrixRTC shared secret (if matrixRtc is enabled) |
mautrix-doublepuppet-as-token | Double puppeting appservice token (shared across bridges) |
mautrix-doublepuppet-hs-token | Same, required by Synapse, never used |
Authentication (MAS)
Section titled “Authentication (MAS)”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.
| Topic | Where it happens |
|---|---|
| SSO login | MAS, Kanidm OIDC client |
| Account portal | https://matrix.domain.tld/account/ |
| Registration tokens | dnf-mas on the host |
| Server administrators | network.matrix.admins, declarative |
| Accounts, sessions, rooms | Ketesa interface |
MAS secrets
Section titled “MAS secrets”-
Generate the three dedicated secrets (idempotent, never overwrites existing ones) :
Fenêtre de terminal just configure-admin-hostSops key Role mas-encryption-secretMAS internal encryption mas-synapse-secretMAS ↔ Synapse shared secret mas-rsa-private-keyToken signing key -
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.
Migrate a server already in production
Section titled “Migrate a server already in production”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.
-
Back up, in this order (the dump must fit within the snapshot) :
Fenêtre de terminal sudo systemctl start postgresqlBackup.servicesudo systemctl start restic-backups-system-main.service -
Deploy the configuration (see above). The maintenance window starts here.
-
Stop both services :
syn2masrequires 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 -
Verify, then do a dry run, then migrate :
Fenêtre de terminal sudo dnf-mas syn2mas checksudo dnf-mas syn2mas migrate --dry-runsudo dnf-mas syn2mas migrateThe report lists accounts, SSO links, access tokens and devices carried over. The hundreds of “ignored” users are the bridges’ ghosts : that’s expected.
-
Restart, MAS first :
Fenêtre de terminal sudo systemctl start matrix-authentication-servicesudo systemctl start matrix-synapse
Inviting friends
Section titled “Inviting friends”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.nixdarkone.service.matrix.friendRegistration.enable = true;-
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 604800The response displays the token, for example
Created user registration token: 9SwPTGIFGQPa. Send it to the friend. -
The friend opens
https://matrix.domain.tld/registerand chooses password-based creation. -
They enter their username and password, then the token, requested at the next step.
-
They then sign in with this username / password pair from any client, Element X included.
Identifier collision with a declared user
Section titled “Identifier collision with a declared user”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 tothat upstream account. Your homeserver does not allow linking an upstreamaccount to an existing accountCheck before declaring a user, on the Matrix host :
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.
-
First declare
bobinetc/config.yamland deploy, so that they get Kanidm credentials. -
Bob signs in on
https://matrix.domain.tld/account/with their friend password. -
In the same browser, they open the link account of the IDM provider :
https://matrix.domain.tld/upstream/authorize/01JDNF0000000000000KAN1DM0 -
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.
If the friend account has nothing to save, it is simpler to declare the DNF
user under another login : the bob identifier is lost for good,
deactivating the friend account will not make it available.
Lock the old account to avoid confusion :
sudo dnf-mas manage lock-user bobManaging bridges
Section titled “Managing bridges”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.nixdarkone.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 :
| Bridge | sops keys | Note |
|---|---|---|
mautrix-whatsapp-as-token, mautrix-whatsapp-hs-token, mautrix-whatsapp-encryption-pickle-key | openssl rand -hex 32 each | |
| Signal | mautrix-signal-as-token, mautrix-signal-hs-token, mautrix-signal-encryption-pickle-key | openssl rand -hex 32 each |
| Messenger | mautrix-meta-as-token, mautrix-meta-hs-token, mautrix-meta-encryption-pickle-key | openssl rand -hex 32 each |
| Telegram | mautrix-telegram-api-id, mautrix-telegram-api-hash, mautrix-telegram-as-token, mautrix-telegram-hs-token | API id/hash to create on my.telegram.org 🡕 |
| Discord | mautrix-discord-as-token, mautrix-discord-hs-token | openssl rand -hex 32 each |
The as/hs tokens make each bridge’s appservice registration deterministic
(see
appservice tokens).
Permissions
Section titled “Permissions”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).
Double puppeting
Section titled “Double puppeting”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.
Audio and video calls
Section titled “Audio and video calls”Two stacks coexist, because neither covers every client. They are activated separately and do not replace each other :
| Legacy WebRTC | MatrixRTC | |
|---|---|---|
| Option | darkone.service.turn.enable | darkone.service.matrix.matrixRtc.enable |
| Components | coturn | LiveKit (SFU) + authorization service |
| Two-person calls | yes | yes |
| Group calls | no | yes |
| Element Classic (Android) | yes | no |
| Element Web / Desktop | yes | yes |
| Element X (Android, iOS) | no | yes |
Keep both enabled : Element Classic only speaks the first, Element X only the second.
-
Enable the MatrixRTC stack in the host’s Nix config :
# usr/machines/<host>/configuration.nixdarkone.service.matrix.matrixRtc.enable = true; # required by Element X -
Generate the shared secret, then deploy :
Fenêtre de terminal just configure-admin-hostjust apply <host> switch -
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 truecurl -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 ports
Section titled “Media ports”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:
| Port | Stack | Usage |
|---|---|---|
| 30000-30100/udp | MatrixRTC | WebRTC media, one port per participant |
| 7881/tcp | MatrixRTC | Fallback for networks that block UDP |
| 3478/udp, 3478/tcp, 5349/tcp | coturn | STUN and TURN negotiation |
| 61000-65535/udp | coturn | Relayed media |
Permanently relayed calls
Section titled “Permanently relayed calls”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:
nix shell nixpkgs#stuntman --command stunclient turn.domain.tld 3478Binding test: success followed by the address as seen by the server: correct.
Binding test: fail: anonymous requests are rejected, every call will be
relayed.
Private peers rejected by the relay
Section titled “Private peers rejected by the relay”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 0This 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).
Opening the network to federation
Section titled “Opening the network to federation”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.nixdarkone.service.matrix.federation = { enable = true; # false = federation totally blocked whitelist = [ ]; # empty = open ; otherwise a whitelist};| Goal | Setting |
|---|---|
| Isolated network (no federation) | enable = false; |
| Open to the whole Matrix ecosystem | enable = true; whitelist = [ ]; |
| Restricted to trusted servers | enable = true; whitelist = [ "ami.org" "matrix.org" ]; |
Check after deployment :
-
Test incoming federation with federationtester.matrix.org 🡕 on
domain.tld→ all indicators green. -
With a whitelist, a non-listed server is refused (Synapse log :
Federation denied), a listed domain works.
Public rooms with approval (knock)
Section titled “Public rooms with approval (knock)”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.
-
In Element: room → Room Settings → Security & Privacy.
-
Room access → “Ask to join” (knock).
-
A user (local or federated remote) requests access; a moderator approves or denies.
Operations
Section titled “Operations”| Action | Commande |
|---|---|
| État des services | systemctl status matrix-synapse mautrix-whatsapp mautrix-signal mautrix-telegram mautrix-meta-messenger |
| Journaux d’un pont | journalctl -u mautrix-whatsapp -f |
| Journaux Synapse | journalctl -u matrix-synapse -f |
| Journaux MAS | journalctl -u matrix-authentication-service -f |
| Journaux MatrixRTC | journalctl -u livekit -u lk-jwt-service -f |
Administer accounts with MAS
Section titled “Administer accounts with MAS”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.
| Action | Command |
|---|---|
| Create a registration token | sudo dnf-mas manage issue-user-registration-token |
| List administrators | sudo dnf-mas manage list-admin-users |
| Remove a legacy promotion | sudo dnf-mas manage demote-admin alice |
| Create an account (bot, service) | sudo dnf-mas manage register-user |
| Change a password | sudo dnf-mas manage set-password alice |
| Kill all sessions for an account | sudo dnf-mas manage kill-sessions alice |
| Lock an account | sudo dnf-mas manage lock-user alice |
| Full diagnostics | sudo 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 hostservices: 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.
| Side | What the interface allows |
|---|---|
| MAS | Sessions (compat, OAuth2, personal), emails, upstream OIDC links, registration tokens, password, lockout, deactivation |
| Synapse | Accounts and devices, rooms, media, reports, rate limits |
Diagnosing a refused SSO connection
Section titled “Diagnosing a refused SSO connection”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:
journalctl -u matrix-authentication-service -f| Message | Cause | Fix |
|---|---|---|
wrong signature alg on /upstream/callback/… | MAS expects an RS256 id_token, Kanidm signs with ES256 | id_token_signed_response_alg of the upstream provider: set to ES256 by the module |
client registration denied by the policy: invalid redirect_uri | The client_uri advertised by the client does not match its redirect scheme | Do not force oidc_metadata in the Element config; its defaults pass |
M_UNRECOGNIZED on /login/sso/redirect | The path goes to Synapse instead of MAS | Check 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 identifier | Merge or rename (see identifier collision) |
| “The QR code is not supported” on the client side | The MSC4108 rendezvous channel is missing | experimental_features.msc4108_enabled: enabled by the module along with MAS |
“The call is not supported”, MISSING_MATRIX_RTC_TRANSPORT | No MatrixRTC backend advertised | Enable matrixRtc.enable (see Audio and video calls) |
| Call that rings but stays silent, or drops after a few seconds | Media ports do not reach the host | Open 30000-30100/udp and 7881/tcp up to it |
| Call slow to establish, or failing outright, with all traffic going through coturn | Clients get no server-reflexive candidates, hence no direct path | coturn must answer anonymous STUN Binding requests (see Permanently relayed calls) |
Quick checks, without logging in:
# org.matrix.msc4108 doit valoir truecurl -A Mozilla/5.0 https://matrix.domain.tld/_matrix/client/versions
# doit répondre 201 et une url publiquecurl -A Mozilla/5.0 -X POST --data probe \ https://matrix.domain.tld/_matrix/client/unstable/org.matrix.msc4108/rendezvousDiagnosing a silent bot
Section titled “Diagnosing a silent bot”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 :
-
Is the service actually running ?
Fenêtre de terminal systemctl status mautrix-<bridge>journalctl -u mautrix-<bridge> -n 50If 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. -
Does the message reach the bridge ? Follow Synapse while sending a
helpto the bot :Fenêtre de terminal journalctl -u matrix-synapse -f | grep transactionsA
PUT .../_matrix/app/v1/transactions/... 200line should appear. If nothing goes out, the appservice is not loaded : check for the registration file in/var/lib/mautrix-<bridge>/and restart Synapse. -
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> -fLook 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). -
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.
-
Last resort : reset the bridge state (box below), then restart Synapse to reload the registration.
See also
Section titled “See also”- Matrix messaging: user guide : bridge connection for users
- Matrix in DNF : architecture and implementation