Tuwunel
High Performance Matrix Homeserver in Rust!
Tuwunel is a featureful Matrix homeserver you can use instead of Synapse with your favorite client, bridge or bot. It is written entirely in Rust to be a scalable, low-cost, enterprise-ready, community-driven alternative, fully implementing the Matrix Specification for all but the most niche uses.
This project is the official successor to conduwuit after it reached stability. Tuwunel is now used by many companies with a vested interest in its continued development by full-time staff. It is primarily sponsored by the government of Switzerland 🇨🇭 where it is currently deployed for citizens.
Getting Started
- GitHub Releases
- Sourcecode
git clone https://github.com/matrix-construct/tuwunel.git - DockerHub or
docker pull jevolk/tuwunel:latest - GHCR or
docker pull ghcr.io/matrix-construct/tuwunel:latest - Static binaries available as releases or build artifacts.
- Deb and RPM packages available as releases or build artifacts.
- Arch package available as tuwunel.
- Nix package available as
matrix-tuwuneland NixOS module available asservices.matrix-tuwunel. - Alpine package available as tuwunel.
- Gentoo ebuild available in Guru as net-im/tuwunel.
- Ansible playbook available as matrix-docker-ansible-deploy.
1. Configure by
copying and editing the tuwunel-example.toml. The server_name and database_path must be
configured. Most users deploy via docker or a distribution package and should follow the
appropriate guide instead.
This is just a summary for the impatient. See the full
documentation.
Tip
Avoid using a sub-domain for your
server_name. You can always delegate later with a.well-knownfile, but you can never change yourserver_name.
2. Setup TLS certificates. Most users enjoy the Caddy reverse-proxy
which automates their certificate renewal. Advanced users can load their own TLS certificates
using the configuration and Tuwunel can be deployed without a reverse proxy. Example
/etc/caddy/Caddyfile configuration with Element
unzipped to /var/www/element:
tuwunel.me, tuwunel.me:8448 {
reverse_proxy localhost:8008
}
web.tuwunel.me {
root * /var/www/element/
file_server
}
caddy reload --config /etc/caddy/Caddyfile
3. Start the server, connect your client and register your username. The first registration is granted server admin.
Tip
Configure a secret
registration_tokenand setallow_registration = true
🤗 Did you find this and other documentation helpful? We would love to hear feedback about setting up Tuwunel.
Migrating to Tuwunel
| Can I migrate from | |
|---|---|
| conduwuit? | ✅ Yes. This will be supported at a minimum for one year, but likely indefinitely. |
| Synapse? | ❌ Not yet, but this is planned and an important issue. Subscribe to #2. |
| Conduit? | ❌ Not right now, but this is planned for the near future. Subscribe to #41. |
| Any other fork of Conduit? | ❌ No. The migration must be explicitly listed in this table. |
Caution
Never switch between different forks of Conduit or you will corrupt your database. All derivatives of Conduit share the same linear database version without any awareness of other forks. The database will permanently corrupt and we will not be able to help you.
Migrating from conduwuit
Migrating from conduwuit to Tuwunel just works. In technical parlance it is a “binary swap.”
All you have to do is update to the latest Tuwunel and change the path to the executable from
conduwuit to tuwunel.
Anything else named “conduwuit” is still recognized, this includes environment variables with prefixes
such as CONDUWUIT_. In fact, CONDUIT_ is still recognized for our legacy users. You may have
noticed that various configs, yamls, services, users, and other items were renamed, but if you
were a conduwuit user we recommend against changing anything at all. This will keep things simple.
If you are not sure please ask. If you found out that something did in fact need to be changed
please open an issue immediately.
Upgrading & Downgrading Tuwunel
We strive to make moving between versions of Tuwunel safe and easy. Downgrading Tuwunel is always safe but often prevented by a guard. An error will indicate the downgrade is not possible and a newer version which does not error must be sought.
Branches
The main branch is always reasonably safe to run. We understand the propensity for users to simply clone the main branch to get up and running, and we’re obliged to ensure it’s always viable. Nevertheless, only tagged releases are true releases.
Container Tracking
Important
We strongly advise tracking the
:latesttag when automatically updating.
Tracking :latest gives us the necessary discretion to keep you on the appropriate stable release
version. Tracking the :preview tag provides select updates of higher confidence between releases.
Tracking the :main branch provides the most frequent updates which have been reviewed and tested
with confidence for release, the only remaining risk being the unknown. The publication frequency
for these tags are on average monthly, weekly and daily, respectively.
Getting Help & Support
If you are opposed to using github, or if private discussion is required such as for security disclosures, or for any other reason, I would be happy to receive your DM at @jason:tuwunel.me. This will not be bothering me as it would be my pleasure to help you when possible. As an emergency contact you can send an email to jasonzemos@gmail.com.
Tuwunel Fanclub
We have an unofficial community-run chat which is publicly accessible at #tuwunel:matrix.org. The members, content, or moderation decisions of this room are not in any way related or endorsed by this project or its sponsors, and not all project staff will be present there. There will be at least some presence by staff to offer assistance so long as the room remains in minimally good standing.
Tuwunel💕
Tuwunel’s theme is empathy in communication defined by the works of Edith Stein. Empathy is the basis for how we approach every message and our responsibility to the other in every conversation.
How can I deploy my own?
If you want to connect an appservice to Tuwunel, take a look at the appservices documentation.
How can I contribute?
See the contributor’s guide
Deploying into service
Read the Generic guide first regardless of which platform you ultimately use. It explains the universal requirements — binary selection, database configuration, the system user, and the systemd unit — that every other guide builds on.
Choosing a reverse proxy
Tuwunel listens on plain HTTP and requires a reverse proxy to terminate TLS. All guides assume HTTPS is already handled externally.
Caddy is the recommended choice. It handles TLS automatically via Let’s Encrypt, and the full Tuwunel configuration fits in two lines. It also proxies port 8448 (Matrix federation) in the same block with no extra work.
Nginx is a solid choice if you are
already running it. The configuration is more verbose but well-documented. Set
client_max_body_size to match or exceed Tuwunel’s max_request_size (default
20 MiB), and never use $request_uri in the proxy pass — it causes subtle
breakage.
Warning
Apache and Lighttpd are not supported for Matrix federation. Both alter the
X-Matrixauthorization header that federation depends on. Nginx and Caddy handle it correctly.
Traefik works well in Docker
environments. Note that Traefik cannot serve .well-known files by itself — you
need a companion nginx container for federation discovery, or expose port 8448
directly as a Traefik entrypoint. Also check whether you are running a version
between 3.6.4–3.6.6 or 2.11.32–2.11.34, which contain a bug that requires an
extra workaround flag.
Root-domain delegation
By default, Tuwunel’s server name is the domain that appears in Matrix user IDs
(@alice:example.com), which must exactly match the host Tuwunel presents when
federating. If you want to host Matrix under a subdomain (matrix.example.com)
while users have addresses on the root domain (example.com), configure
root-domain delegation. This serves
.well-known/matrix/client and .well-known/matrix/server from the root domain
pointing to the subdomain, and requires no changes to DNS beyond what is already
needed for your web server.
Things to know before getting started
Pick the right binary. Prebuilt binaries for x86_64 come in -v1-, -v2-,
and -v3- CPU-optimized variants. Running the wrong one produces an
Illegal Instruction crash. The generic guide includes a command to check which
variant your CPU supports; -v2- or better is recommended for RocksDB’s CRC32
performance.
RocksDB is the only supported database. SQLite support has been removed. If you are migrating from Conduit, you will need a migration tool before deploying Tuwunel.
Port 8448 matters for federation. Clients connect on port 443, but other Matrix homeservers connect on port 8448. Both must be reachable for a fully functional server. NAT hairpinning or split-horizon DNS may be needed for internal clients that need to reach the same domain.
Container images are minimal. Docker and Podman images contain only the
binary, a minimal init (tini), and CA certificates — no shell. If you need to
inspect a running container you will need to exec into it using the binary
directly.
Rootless Podman requires linger. Without loginctl enable-linger, rootless
containers stop when the user logs out. The Podman guide
uses quadlet files and user-level systemd to handle this correctly.
NixOS has platform-specific workarounds. The community services.matrix-conduit
NixOS module defaults to SQLite, requires a manual workaround for UNIX socket
support, and conflicts with jemalloc when a hardened profile is enabled. The
NixOS guide covers all three.
Configuration
This chapter describes various ways to configure Tuwunel.
Basics
Tuwunel uses a config file for the majority of the settings, but also supports setting individual config options via commandline.
Please refer to the example config file for all of those settings.
The config file to use can be specified on the commandline when running
Tuwunel by specifying the -c, --config flag. It is also possible to specify
more than one config file.
Alternatively, you can use the environment variable TUWUNEL_CONFIG to specify
the config file to used. Conduit’s environment variables are supported for
backwards compatibility.
Important
It is bad practice to uncomment default options without changing them. Many defaults are updated by developers as features evolve and can be essential to expected server function.
Option commandline flag
Tuwunel supports setting individual config options in TOML format from the
-O / --option flag. For example, you can set your server name via -O server_name=\"example.com\".
Note that the config is parsed as TOML, and shells like bash will remove quotes. So unfortunately it is required to escape quotes if the config option takes a string. This does not apply to options that take booleans or numbers:
--option allow_registration=trueworks ✅-O max_request_size=99999999works ✅-O server_name=example.comdoes not work ❌--option log=\"debug\"works ✅--option server_name='"example.com"'works ✅
Relevance of configuration settings
There is a specific sequence for reading and overwriting the settings. The latest setting takes precedence and defines the configuration.
- Set in
CONDUIT_CONFIG. - Set in
CONDUWUIT_CONFIG. - Set in
TUWUNEL_CONFIG. - Set in the first config file on the command line (e.g.
-c config_file_1.toml). - Set in the second config file on the command line (e.g.
-c config_file_2.toml). - Set in any additional config file on the command line (e.g.
-c config_file_n.toml). - Set within the options (again, the latest option in the list overrides).
Environment variables
All of the settings that are found in the config file can be specified by using
environment variables. The environment variable names should be all caps and
prefixed with TUWUNEL_.
For example, if the setting you are changing is max_request_size, then the
environment variable to set is TUWUNEL_MAX_REQUEST_SIZE.
To modify config options not in the [global] context such as
[global.well_known], use the __ suffix split: TUWUNEL_WELL_KNOWN__SERVER
Conduit and conduwuit’s environment variables are supported for backwards
compatibility (e.g. CONDUIT_SERVER_NAME or CONDUWUIT_SERVER_NAME).
Execute commandline flag
Tuwunel supports running admin commands on startup using the commandline
argument --execute. The most notable use for this is to create an admin user
on first startup.
The syntax of this is a standard admin command without the prefix such as
./tuwunel --execute "users create_user june"
An example output of a success is:
INFO tuwunel_service::admin::startup: Startup command #0 completed:
Created user with user_id: @june:girlboss.ceo and password: `<redacted>`
This commandline argument can be paired with the --option flag.
Examples
Example configuration
Example configuration
### Tuwunel Configuration
###
### THIS FILE IS GENERATED. CHANGES/CONTRIBUTIONS IN THE REPO WILL BE
### OVERWRITTEN!
###
### You should rename this file before configuring your server. Changes to
### documentation and defaults can be contributed in source code at
### src/core/config/mod.rs. This file is generated when building.
###
### Any values pre-populated are the default values for said config option.
###
### At the minimum, you MUST edit all the config options to your environment
### that say "YOU NEED TO EDIT THIS".
###
### For more information, see:
### https://tuwunel.chat/configuration.html
[global]
# The server_name is the pretty name of this server. It is used as a
# suffix for user and room IDs/aliases.
#
# See the docs for reverse proxying and delegation:
# https://tuwunel.chat/deploying/generic.html#setting-up-the-reverse-proxy
#
# Also see the `[global.well_known]` config section at the very bottom.
#
# Examples of delegation:
# - https://matrix.org/.well-known/matrix/server
# - https://matrix.org/.well-known/matrix/client
#
# YOU NEED TO EDIT THIS. THIS CANNOT BE CHANGED AFTER WITHOUT A DATABASE
# WIPE.
#
# example: "girlboss.ceo"
#
#server_name =
# This is the only directory where tuwunel will save its data, including
# media. Note: this was previously "/var/lib/matrix-conduit".
#
#database_path = "/var/lib/tuwunel"
# Text which will be added to the end of the user's displayname upon
# registration with a space before the text. In Conduit, this was the
# lightning bolt emoji.
#
# To disable, set this to "" (an empty string).
#
# reloadable: yes
#
#new_user_displayname_suffix = "💕"
# The default address (IPv4 or IPv6) tuwunel will listen on.
#
# If you are using Docker or a container NAT networking setup, this must
# be "0.0.0.0".
#
# To listen on multiple addresses, specify a vector e.g. ["127.0.0.1",
# "::1"]
#
#address = ["127.0.0.1", "::1"]
# The port(s) tuwunel will listen on.
#
# For reverse proxying, see:
# https://tuwunel.chat/deploying/generic.html#setting-up-the-reverse-proxy
#
# If you are using Docker, don't change this, you'll need to map an
# external port to this.
#
# To listen on multiple ports, specify a vector e.g. [8080, 8448]
#
#port = 8008
# The UNIX socket tuwunel will listen on.
#
# Remember to make sure that your reverse proxy has access to this socket
# file, either by adding your reverse proxy to the 'tuwunel' group or
# granting world R/W permissions with `unix_socket_perms` (666 minimum).
#
# example: "/run/tuwunel/tuwunel.sock"
#
#unix_socket_path =
# The default permissions (in octal) to create the UNIX socket with.
#
#unix_socket_perms = 660
# Error on startup if any config option specified is unknown to Tuwunel.
#
# This is false by default to allow easier deprecation or removal of
# config options in the future without breaking existing deployments. The
# default behaviour is to simply warn on startup.
# reloadable: yes
#
#error_on_unknown_config_opts = false
# tuwunel supports online database backups using RocksDB's Backup engine
# API. To use this, set a database backup path that tuwunel can write
# to.
#
# For more information, see:
# https://tuwunel.chat/maintenance.html#backups
#
# reloadable: yes
# example: "/opt/tuwunel-db-backups"
#
#database_backup_path =
# The amount of online RocksDB database backups to keep/retain, if using
# "database_backup_path", before deleting the oldest one.
#
# reloadable: yes
#
#database_backups_to_keep = 1
# Set this to any float value to multiply tuwunel's in-memory LRU caches
# with such as "auth_chain_cache_capacity".
#
# May be useful if you have significant memory to spare to increase
# performance.
#
# If you have low memory, reducing this may be viable.
#
# By default, the individual caches such as "auth_chain_cache_capacity"
# are scaled by your CPU core count.
#
#cache_capacity_modifier = 1.0
# Set this to any float value in megabytes for tuwunel to tell the
# database engine that this much memory is available for database read
# caches.
#
# May be useful if you have significant memory to spare to increase
# performance.
#
# Similar to the individual LRU caches, this is scaled up with your CPU
# core count.
#
# This defaults to 128.0 + (64.0 * CPU core count).
#
#db_cache_capacity_mb = varies by system
# Set this to any float value in megabytes for tuwunel to tell the
# database engine that this much memory is available for database write
# caches.
#
# May be useful if you have significant memory to spare to increase
# performance.
#
# Similar to the individual LRU caches, this is scaled up with your CPU
# core count.
#
# This defaults to 48.0 + (4.0 * CPU core count).
#
#db_write_buffer_capacity_mb = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#pdu_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#auth_chain_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#shorteventid_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#eventidshort_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#eventid_pdu_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#shortstatekey_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#statekeyshort_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#servernameevent_data_cache_capacity = varies by system
# This item is undocumented. Please contribute documentation for it.
#
#stateinfo_cache_capacity = varies by system
# Minimum time-to-live in seconds for room summary entries in the spaces
# cache.
#
# reloadable: yes
#
#spacehierarchy_cache_ttl_min = 21600
# Maximum time-to-live in seconds for room summary entries in the spaces
# cache.
#
# reloadable: yes
#
#spacehierarchy_cache_ttl_max = 129600
# Minimum timeout a client can request for long-polling sync. Requests
# will be clamped up to this value if smaller.
#
# reloadable: yes
#
#client_sync_timeout_min = 5000
# Default timeout for long-polling sync if a client does not request
# another in their query-string.
#
# reloadable: yes
#
#client_sync_timeout_default = 30000
# Maximum timeout a client can request for long-polling sync. Requests
# will be clamped down to this value if larger.
#
# reloadable: yes
#
#client_sync_timeout_max = 90000
# Maximum entries stored in DNS memory-cache. The size of an entry may
# vary so please take care if raising this value excessively. Only
# decrease this when using an external DNS cache. Please note that
# systemd-resolved does *not* count as an external cache, even when
# configured to do so.
#
#dns_cache_entries = 32768
# Minimum time-to-live in seconds for entries in the DNS cache. The
# default may appear high to most administrators; this is by design as the
# exotic loads of federating to many other servers require a higher TTL
# than many domains have set. Even when using an external DNS cache the
# problem is shifted to that cache which is ignorant of its role for
# this application and can adhere to many low TTL's increasing its load.
#
#dns_min_ttl = 10800
# Minimum time-to-live in seconds for NXDOMAIN entries in the DNS cache.
# This value is critical for the server to federate efficiently.
# NXDOMAIN's are assumed to not be returning to the federation and
# aggressively cached rather than constantly rechecked.
#
# Defaults to 3 days as these are *very rarely* false negatives.
#
#dns_min_ttl_nxdomain = 259200
# Number of DNS nameserver retries after a timeout or error.
#
#dns_attempts = 10
# The number of seconds to wait for a reply to a DNS query. Please note
# that recursive queries can take up to several seconds for some domains,
# so this value should not be too low, especially on slower hardware or
# resolvers.
#
#dns_timeout = 10
# Fallback to TCP on DNS errors. Set this to false if unsupported by
# nameserver.
#
#dns_tcp_fallback = true
# Enable to query all nameservers until the domain is found. Referred to
# as "trust_negative_responses" in hickory_resolver. This can avoid
# useless DNS queries if the first nameserver responds with NXDOMAIN or
# an empty NOERROR response.
#
#query_all_nameservers = true
# Enable using *only* TCP for querying your specified nameservers instead
# of UDP.
#
# If you are running tuwunel in a container environment, this config
# option may need to be enabled. For more details, see:
# https://tuwunel.chat/troubleshooting.html#potential-dns-issues-when-using-docker
#
#query_over_tcp_only = false
# DNS A/AAAA record lookup strategy
#
# Takes a number of one of the following options:
# 1 - Ipv4Only (Only query for A records, no AAAA/IPv6)
#
# 2 - Ipv6Only (Only query for AAAA records, no A/IPv4)
#
# 3 - Ipv4AndIpv6 (Query for A and AAAA records in parallel, uses whatever
# returns a successful response first)
#
# 4 - Ipv6thenIpv4 (Query for AAAA record, if that fails then query the A
# record)
#
# 5 - Ipv4thenIpv6 (Query for A record, if that fails then query the AAAA
# record)
#
# If you don't have IPv6 networking, then for better DNS performance it
# may be suitable to set this to Ipv4Only (1) as you will never ever use
# the AAAA record contents even if the AAAA record is successful instead
# of the A record.
#
#ip_lookup_strategy = 5
# List of domain patterns resolved via the alternative path without any
# persistent cache, very small memory cache, and no enforced TTL. This
# is intended for internal network and application services which require
# these specific properties. This path does not support federation or
# general purposes.
#
# reloadable: yes
# example: ["*\.dns\.podman$"]
#
#dns_passthru_domains = []
# Whether to resolve appservices via the alternative path; setting this is
# superior to providing domains in `dns_passthru_domains` if all
# appservices intend to be matched anyway. The overhead of matching regex
# and maintaining the list of domains can be avoided.
#
#dns_passthru_appservices = false
# Enable or disable case randomization for DNS queries. This is a security
# mitigation where answer spoofing is prevented by having to exactly match
# the question. Occasional errors seen in logs which may have lead you
# here tend to be from overloading DNS. Nevertheless for servers which
# are truly incapable this can be set to false.
#
# This currently defaults to false due to user reports regarding some
# popular DNS caches which may or may not be patched soon. It may again
# default to true in an upcoming release.
#
#dns_case_randomization = false
# Max request size for file uploads. Accepts an integer byte count or a
# string with SI/IEC suffix such as "24 MiB".
#
#max_request_size = 24 MiB
# Maximum number of concurrently pending (asynchronous) media uploads a
# user can have.
#
# reloadable: yes
#
#max_pending_media_uploads = 5
# The time in seconds before an unused pending MXC URI expires and is
# removed.
#
# reloadable: yes
#
#media_create_unused_expiration_time = 86400 (24 hours)
# The maximum number of media create requests per second allowed from a
# single user.
#
# reloadable: yes
#
#media_rc_create_per_second = 10
# The maximum burst count for media create requests from a single user.
#
# reloadable: yes
#
#media_rc_create_burst_count = 50
# This item is undocumented. Please contribute documentation for it.
# reloadable: yes
#
#max_fetch_prev_events = 192
# Default/base connection timeout (seconds). This is used only by URL
# previews and update/news endpoint checks.
#
#request_conn_timeout = 10
# Default/base request timeout (seconds). The time waiting to receive more
# data from another server. This is used only by URL previews,
# update/news, and misc endpoint checks.
#
#request_timeout = 35
# Default/base request total timeout (seconds). The time limit for a whole
# request. This is set very high to not cancel healthy requests while
# serving as a backstop. This is used only by URL previews and update/news
# endpoint checks.
#
#request_total_timeout = 320
# Default/base idle connection pool timeout (seconds). This is used only
# by URL previews and update/news endpoint checks.
#
#request_idle_timeout = 5
# Default/base max idle connections per host. This is used only by URL
# previews and update/news endpoint checks. Defaults to 1 as generally the
# same open connection can be re-used.
#
#request_idle_per_host = 1
# Federation well-known resolution connection timeout (seconds).
#
#well_known_conn_timeout = 6
# Federation HTTP well-known resolution request timeout (seconds).
#
#well_known_timeout = 10
# Federation client request timeout (seconds). You most definitely want
# this to be high to account for extremely large room joins, slow
# homeservers, your own resources etc.
#
#federation_timeout = 300
# Federation client idle connection pool timeout (seconds).
#
#federation_idle_timeout = 25
# Federation client max idle connections per host. Defaults to 1 as
# generally the same open connection can be re-used.
#
#federation_idle_per_host = 1
# Federation sender request timeout (seconds). The time it takes for the
# remote server to process sent transactions can take a while.
#
#sender_timeout = 180
# Federation sender idle connection pool timeout (seconds).
#
#sender_idle_timeout = 180
# Federation sender transaction retry backoff limit (seconds).
#
# reloadable: yes
#
#sender_retry_backoff_limit = 86400
# Appservice URL request connection timeout. Defaults to 35 seconds as
# generally appservices are hosted within the same network.
#
#appservice_timeout = 35
# Appservice URL idle connection pool timeout (seconds).
#
#appservice_idle_timeout = 300
# Notification gateway pusher idle connection pool timeout.
#
#pusher_idle_timeout = 15
# Maximum time to receive a request from a client (seconds).
#
#client_receive_timeout = 75
# Maximum time to process a request received from a client (seconds).
#
#client_request_timeout = 240
# Maximum time to transmit a response to a client (seconds)
#
#client_response_timeout = 120
# Grace period for clean shutdown of client requests (seconds).
#
# reloadable: yes
#
#client_shutdown_timeout = 10
# Source of the client IP address for rate limiting, logging, and
# security tooling.
#
# When unset (the default), the `ClientIp` extractor falls back to
# `axum-client-ip`'s `InsecureClientIp` for backward compatibility;
# clients can spoof their address via request headers in that mode.
#
# When set, tuwunel installs `SecureClientIpSource` with the selected
# variant and `ClientIp` resolves exclusively from that source. The
# rightmost value is used for multi-valued headers; only the proxy can
# append to the right, so this is resistant to client spoofing.
#
# Supported values:
# - "connect_info" - TCP peer address only (direct connections)
# - "rightmost_x_forwarded_for" - nginx, Caddy
# - "rightmost_forwarded" - RFC 7239 proxies
# - "x_real_ip" - nginx `X-Real-IP`
# - "cf_connecting_ip" - Cloudflare / cloudflared
# - "true_client_ip" - Akamai, Cloudflare Enterprise
# - "fly_client_ip" - Fly.io
# - "cloudfront_viewer_address" - AWS CloudFront
#
# On Unix-socket deployments, leave this unset rather than setting
# "connect_info"; that source requires a TCP peer address.
#
# WARNING: a header-based value without a trusted reverse proxy in
# front of tuwunel allows clients to forge their IP. Changing this
# value requires a server restart.
#
#ip_source = "connect_info"
# Grace period for clean shutdown of federation requests (seconds).
#
# reloadable: yes
#
#sender_shutdown_timeout = 5
# Enables registration. If set to false, no users can register on this
# server.
#
# If set to true without a token configured, users can register with no
# form of 2nd-step only if you set the following option to true:
# `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse`
#
# If you would like registration only via token reg, please configure
# `registration_token` or `registration_token_file`.
# reloadable: yes
#
#allow_registration = false
# Enabling this setting opens registration to anyone without restrictions.
# This makes your server vulnerable to abuse
# reloadable: yes
#
#yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = false
# A static registration token that new users will have to provide when
# creating an account. If unset and `allow_registration` is true,
# you must set
# `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse`
# to true to allow open registration without any conditions.
#
# YOU NEED TO EDIT THIS OR USE registration_token_file.
#
# reloadable: yes
# example: "o&^uCtes4HPf0Vu@F20jQeeWE7"
#
#registration_token =
# Path to a file on the system that gets read for additional registration
# tokens. Multiple tokens can be added if you separate them with
# whitespace
#
# tuwunel must be able to access the file, and it must not be empty
#
# reloadable: yes
# example: "/etc/tuwunel/.reg_token"
#
#registration_token_file =
# Controls whether encrypted rooms and events are allowed.
# reloadable: yes
#
#allow_encryption = true
# Controls whether locally-created rooms should be end-to-end encrypted by
# default. This option is equivalent to the one found in Synapse.
#
# Options:
# - "all": All created rooms are encrypted.
# - "invite": Any room created with `private_chat` or
# `trusted_private_chat` presets.
# - "none": Explicit value for no effect.
# - Other values default to no effect.
#
# reloadable: yes
#
#encryption_enabled_by_default_for_room_type = "none"
# Controls whether federation is allowed or not. It is not recommended to
# disable this after installation due to potential federation breakage but
# this is technically not a permanent setting.
#
#allow_federation = true
# Sets the default `m.federate` property for newly created rooms when the
# client does not request one. If `allow_federation` is set to false at
# the same this value is set to false it then always overrides the client
# requested `m.federate` value to false.
#
# Rooms are fixed to the setting at the time of their creation and can
# never be changed; changing this value only affects new rooms.
# reloadable: yes
#
#federate_created_rooms = true
# Allows federation requests to be made to itself
#
# This isn't intended and is very likely a bug if federation requests are
# being sent to yourself. This currently mainly exists for development
# purposes.
# reloadable: yes
#
#federation_loopback = false
# Always calls /forget on behalf of the user if leaving a room. This is a
# part of MSC4267 "Automatically forgetting rooms on leave"
# reloadable: yes
#
#forget_forced_upon_leave = false
# Set this to true to require authentication on the normally
# unauthenticated profile retrieval endpoints (GET)
# "/_matrix/client/v3/profile/{userId}".
#
# This can prevent profile scraping.
# reloadable: yes
#
#require_auth_for_profile_requests = false
# Preserve per-room profile overrides during a global profile update.
#
# When `true` (default), a profile change (displayname or avatar_url)
# arriving via the profile endpoints skips rooms whose current
# `m.room.member` already differs from the user's prior global
# profile. This is the natural behavior users expect after setting a
# per-room nickname or avatar with a client's `/myroomnick`-style
# command: a subsequent global change does not clobber the override.
#
# Set to `false` to always rewrite every joined room's member event
# to match the new global profile. That matches the literal spec
# reading.
#
# MSC4466 lets clients pick this per request via the
# `org.matrix.msc4466.propagate_to` query parameter
# (`all` / `unchanged` / `none`); an explicit value overrides this
# default in either direction.
#
# reloadable: yes
#
#preserve_room_profile_overrides = true
# Set this to true to allow your server's public room directory to be
# federated. Set this to false to protect against /publicRooms spiders,
# but will forbid external users from viewing your server's public room
# directory. If federation is disabled entirely (`allow_federation`), this
# is inherently false.
# reloadable: yes
#
#allow_public_room_directory_over_federation = false
# Set this to true to allow your server's public room directory to be
# queried without client authentication (access token) through the Client
# APIs. Set this to false to protect against /publicRooms spiders.
# reloadable: yes
#
#allow_public_room_directory_without_auth = false
# Allows room directory searches to match on partial room_id's when the
# search term starts with '!'.
#
# reloadable: yes
#
#allow_public_room_search_by_id = true
# Set this to false to limit results of rooms when searching by ID to
# those that would be found by an alias or other query; specifically
# those listed in the public rooms directory. By default this is set to
# true allowing any joinable room to match. This satisfies the Principle
# of Least Expectation when pasting a room_id into a search box with
# intent to join; many rooms simply opt-out of public listings. Therefor
# to prevent this feature from abuse, knowledge of several characters of
# the room_id is required before any results are returned.
#
# reloadable: yes
#
#allow_unlisted_room_search_by_id = true
# Show all local users in user directory. With this set to false, only
# users in public rooms or those that share a room with the user making
# the search will be shown.
#
# reloadable: yes
#
#show_all_local_users_in_user_directory = false
# Allow guests/unauthenticated users to access TURN credentials.
#
# This is the equivalent of Synapse's `turn_allow_guests` config option.
# This allows any unauthenticated user to call the endpoint
# `/_matrix/client/v3/voip/turnServer`.
#
# It is unlikely you need to enable this as all major clients support
# authentication for this endpoint and prevents misuse of your TURN server
# from potential bots.
# reloadable: yes
#
#turn_allow_guests = false
# Set this to true to lock down your server's public room directory and
# only allow admins to publish rooms to the room directory. Unpublishing
# is still allowed by all users with this enabled.
# reloadable: yes
#
#lockdown_public_room_directory = false
# Set this to true to allow federating device display names / allow
# external users to see your device display name. If federation is
# disabled entirely (`allow_federation`), this is inherently false. For
# privacy reasons, this is best left disabled.
# reloadable: yes
#
#allow_device_name_federation = false
# Config option to allow or disallow incoming federation requests that
# obtain the profiles of our local users from
# `/_matrix/federation/v1/query/profile`
#
# Increases privacy of your local user's such as display names, but some
# remote users may get a false "this user does not exist" error when they
# try to invite you to a DM or room. Also can protect against profile
# spiders.
#
# This is inherently false if `allow_federation` is disabled
# reloadable: yes
#
#allow_inbound_profile_lookup_federation_requests = true
# Allow standard users to create rooms. Appservices and admins are always
# allowed to create rooms
# reloadable: yes
#
#allow_room_creation = true
# Set to false to disable users from joining or creating room versions
# that aren't officially supported by tuwunel. Unstable room versions may
# have flawed specifications or our implementation may be non-conforming.
# Correct operation may not be guaranteed, but incorrect operation may be
# tolerable and unnoticed.
#
# tuwunel officially supports room versions 6+. tuwunel has slightly
# experimental (though works fine in practice) support for versions 3 - 5.
#
# reloadable: yes
#
#allow_unstable_room_versions = true
# Set to true to enable experimental room versions.
#
# Unlike unstable room versions these versions are either under
# development, protype spec-changes, or somehow present a serious risk to
# the server's operation or database corruption. This is for developer use
# only.
# reloadable: yes
#
#allow_experimental_room_versions = false
# MSC4284: ask the room's policy server to sign outgoing events. When a
# room has a valid `m.room.policy` state event, the homeserver requests a
# signature from that policy server's federation `/sign` endpoint before
# federating each event. Refusal aborts the local request; network or
# timeout failures fail open with a warn log so a transient policy-server
# outage does not silently take the room offline.
#
# reloadable: yes
#
#enable_policy_servers = false
# MSC4284: timeout (seconds) for requests to a room's policy server.
# Applies to both outbound `/sign` calls and inbound signature-fetches.
#
# reloadable: yes
#
#policy_server_request_timeout = 5
# Default room version tuwunel will create rooms with.
#
# The default is prescribed by the spec, but may be selected by developer
# recommendation. To prevent stale documentation we no longer list it
# here. It is only advised to override this if you know what you are
# doing, and by doing so, updates with new versions are precluded.
# reloadable: yes
#
#default_room_version =
# This item is undocumented. Please contribute documentation for it.
#
#allow_jaeger = false
# This item is undocumented. Please contribute documentation for it.
#
#jaeger_filter = "info"
# If the 'perf_measurements' compile-time feature is enabled, enables
# collecting folded stack trace profile of tracing spans using
# tracing_flame. The resulting profile can be visualized with inferno[1],
# speedscope[2], or a number of other tools.
#
# [1]: https://github.com/jonhoo/inferno
# [2]: www.speedscope.app
#
#tracing_flame = false
# This item is undocumented. Please contribute documentation for it.
#
#tracing_flame_filter = "info"
# This item is undocumented. Please contribute documentation for it.
#
#tracing_flame_output_path = "./tracing.folded"
# Examples:
#
# - No proxy (default):
#
# proxy = "none"
#
# - For global proxy, create the section at the bottom of this file:
#
# [global.proxy]
# global = { url = "socks5h://localhost:9050" }
#
# - To proxy some domains:
#
# [global.proxy]
# [[global.proxy.by_domain]]
# url = "socks5h://localhost:9050"
# include = ["*.onion", "matrix.myspecial.onion"]
# exclude = ["*.myspecial.onion"]
#
# Include vs. Exclude:
#
# - If include is an empty list, it is assumed to be `["*"]`.
#
# - If a domain matches both the exclude and include list, the proxy will
# only be used if it was included because of a more specific rule than
# it was excluded. In the above example, the proxy would be used for
# `ordinary.onion`, `matrix.myspecial.onion`, but not
# `hello.myspecial.onion`.
#
#proxy = "none"
# Servers listed here will be used to gather public keys of other servers
# (notary trusted key servers).
#
# Currently, tuwunel doesn't support inbound batched key requests, so
# this list should only contain other Synapse servers.
#
# reloadable: yes
# example: ["matrix.org", "tchncs.de"]
#
#trusted_servers = ["matrix.org"]
# Whether to query the servers listed in trusted_servers first or query
# the origin server first. For best security, querying the origin server
# first is advised to minimize the exposure to a compromised trusted
# server. For maximum federation/join performance this can be set to true,
# however other options exist to query trusted servers first under
# specific high-load circumstances and should be evaluated before setting
# this to true.
# reloadable: yes
#
#query_trusted_key_servers_first = false
# Whether to query the servers listed in trusted_servers first
# specifically on room joins. This option limits the exposure to a
# compromised trusted server to room joins only. The join operation
# requires gathering keys from many origin servers which can cause
# significant delays. Therefor this defaults to true to mitigate
# unexpected delays out-of-the-box. The security-paranoid or those willing
# to tolerate delays are advised to set this to false. Note that setting
# query_trusted_key_servers_first to true causes this option to be
# ignored.
# reloadable: yes
#
#query_trusted_key_servers_first_on_join = true
# Only query trusted servers for keys and never the origin server. This is
# intended for clusters or custom deployments using their trusted_servers
# as forwarding-agents to cache and deduplicate requests. Notary servers
# do not act as forwarding-agents by default, therefor do not enable this
# unless you know exactly what you are doing.
# reloadable: yes
#
#only_query_trusted_key_servers = false
# Maximum number of keys to request in each trusted server batch query.
#
# reloadable: yes
#
#trusted_server_batch_size = 192
# Maximum number of request batches in flight simultaneously when querying
# a trusted server.
#
# reloadable: yes
#
#trusted_server_batch_concurrency = 2
# Max log level for tuwunel. Allows debug, info, warn, or error.
#
# See also:
# https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives
#
# **Caveat**:
# For release builds, the tracing crate is configured to only implement
# levels higher than error to avoid unnecessary overhead in the compiled
# binary from trace macros. For debug builds, this restriction is not
# applied.
#
#log = "info"
# Output logs with ANSI colours.
#
#log_colors = true
# Sets the log format to compact mode.
#
#log_compact = false
# Configures the span events which will be outputted with the log.
#
#log_span_events = "none"
# Configures whether TUWUNEL_LOG EnvFilter matches values using regular
# expressions. See the tracing_subscriber documentation on Directives.
#
#log_filter_regex = true
# Toggles the display of ThreadId in tracing log output.
#
#log_thread_ids = false
# Redirects logging to standard error (stderr). The default is false for
# stdout. For those using our systemd features the redirection to stderr
# occurs as necessary and setting this option should not be required. We
# offer this option for all other users who desire such redirection.
#
#log_to_stderr = false
# Setting to false disables the logging/tracing system at a lower level.
# In contrast to configuring an empty `log` string where the system is
# still operating but muted, when this option is false the system was not
# initialized and is not operating. Changing this option has no effect
# after startup. This option is intended for developers and expert use
# only: configuring an empty log string is preferred over using this.
#
#log_enable = true
# Setting to false disables the logging/tracing system at a lower level
# similar to `log_enable`. In this case the system is configured normally,
# but not registered as the global handler in the final steps. This option
# is for developers and expert use only.
#
#log_global_default = true
# OpenID token expiration/TTL in seconds.
#
# These are the OpenID tokens that are primarily used for Matrix account
# integrations (e.g. Vector Integrations in Element), *not* OIDC/OpenID
# Connect/etc.
#
# reloadable: yes
#
#openid_token_ttl = 3600
# Allow an existing session to mint a login token for another client.
# This requires interactive authentication, but has security ramifications
# as a malicious client could use the mechanism to spawn more than one
# session. Enabled by default.
#
# reloadable: yes
#
#login_via_existing_session = true
# Whether to enable the login token route to accept login tokens at all.
# Login tokens may be generated by the server for authorization flows such
# as SSO; disabling tokens may break such features.
#
# This option is distinct from `login_via_existing_session` and does not
# carry the same security implications; the intent is to leave this
# enabled while disabling the former to prevent clients from commanding
# login token creation but without preventing the server from doing so.
#
# reloadable: yes
#
#login_via_token = true
# Whether to enable login using traditional user/password authorization
# flow.
#
# Set this option to false if you intend to allow logging in only using
# other mechanisms, such as SSO.
#
# reloadable: yes
#
#login_with_password = true
# Login token expiration/TTL in milliseconds.
#
# These are short-lived tokens for the m.login.token endpoint.
# This is used to allow existing sessions to create new sessions.
# see login_via_existing_session.
#
# reloadable: yes
#
#login_token_ttl = 120000
# Access token TTL in seconds.
#
# For clients that support refresh-tokens, the access-token provided on
# login will be invalidated after this amount of time and the client will
# be soft-logged-out until refreshing it.
#
# reloadable: yes
#
#access_token_ttl = 604800
# Refresh token TTL in seconds.
#
# Refresh tokens are rejected once this lifetime elapses. Whether the
# deadline slides forward on each use or stays fixed at issuance is
# controlled by `refresh_token_idle_only`. The default of `0` disables
# refresh-token expiry entirely; a typical enabled value is `259200`
# (three days).
#
# reloadable: yes
#
#refresh_token_ttl = 0
# Whether `refresh_token_ttl` acts as an idle timeout or an absolute
# session lifetime.
#
# When `true` (default), each successful refresh resets the deadline to
# `now + refresh_token_ttl`. A session in continuous use never expires.
# When `false`, the deadline is fixed at first issuance and rotation
# carries it forward, forcing re-auth after `refresh_token_ttl`
# regardless of activity.
#
# reloadable: yes
#
#refresh_token_idle_only = true
# Whether refresh-token expiry triggers a hard logout instead of a soft
# one.
#
# When `false` (default), an expired refresh token is rejected with
# `M_UNKNOWN_TOKEN` carrying `soft_logout: true`. The client can preserve
# E2EE keys and local state, then re-authenticate to resume the same
# device.
#
# When `true`, the device is removed entirely on expiry: the access
# token is invalidated, the device record is deleted, and the client is
# signalled with `soft_logout: false`. The next session is a brand-new
# device, so the client cannot recover E2EE history from local state
# alone; this is the CWE-613 stance and trades usability for that
# guarantee.
#
# reloadable: yes
#
#refresh_token_hard_logout = false
# Static TURN username to provide the client if not using a shared secret
# ("turn_secret"), It is recommended to use a shared secret over static
# credentials.
# reloadable: yes
#
#turn_username = false
# Static TURN password to provide the client if not using a shared secret
# ("turn_secret"). It is recommended to use a shared secret over static
# credentials.
#
# reloadable: yes
#
#turn_password = false
# Vector list of TURN URIs/servers to use.
#
# Replace "example.turn.uri" with your TURN domain, such as the coturn
# "realm" config option. If using TURN over TLS, replace the URI prefix
# "turn:" with "turns:".
#
# reloadable: yes
# example: ["turn:example.turn.uri?transport=udp",
# "turn:example.turn.uri?transport=tcp"]
#
#turn_uris = []
# TURN secret to use for generating the HMAC-SHA1 hash apart of username
# and password generation.
#
# This is more secure, but if needed you can use traditional static
# username/password credentials.
#
#turn_secret = false
# TURN secret to use that's read from the file path specified.
#
# This takes priority over "turn_secret" first, and falls back to
# "turn_secret" if invalid or failed to open.
#
# example: "/etc/tuwunel/.turn_secret"
#
#turn_secret_file =
# TURN TTL, in seconds.
#
# reloadable: yes
#
#turn_ttl = 86400
# List/vector of room IDs or room aliases that tuwunel will make newly
# registered users join. The rooms specified must be rooms that you have
# joined at least once on the server, and must be public.
#
# reloadable: yes
# example: ["#tuwunel:grin.hu",
# "!l2xV0sd51lraysuRcsWVECge4NULaH3g-ou95vgDgiM"]
#
#auto_join_rooms = []
# Config option to automatically deactivate the account of any user who
# attempts to join a:
# - banned room
# - forbidden room alias
# - room alias or ID with a forbidden server name
#
# This may be useful if all your banned lists consist of toxic rooms or
# servers that no good faith user would ever attempt to join, and
# to automatically remediate the problem without any admin user
# intervention.
#
# This will also make the user leave all rooms. Federation (e.g. remote
# room invites) are ignored here.
#
# Defaults to false as rooms can be banned for non-moderation-related
# reasons and this performs a full user deactivation.
# reloadable: yes
#
#auto_deactivate_banned_room_attempts = false
# RocksDB log level. This is not the same as tuwunel's log level. This
# is the log level for the RocksDB engine/library which show up in your
# database folder/path as `LOG` files. tuwunel will log RocksDB errors
# as normal through tracing or panics if severe for safety.
#
#rocksdb_log_level = "error"
# This item is undocumented. Please contribute documentation for it.
#
#rocksdb_log_stderr = false
# Max RocksDB `LOG` file size before rotating. Accepts an integer byte
# count or a string with SI/IEC suffix such as "4 MiB".
#
#rocksdb_max_log_file_size = 4194304
# Time in seconds before RocksDB will forcibly rotate logs.
#
#rocksdb_log_time_to_roll = 0
# Use RocksDB tunings tailored to spinning disks (HDDs). On NVMe or SSD
# storage, leave this disabled.
#
# When enabled, RocksDB skips compaction readahead and parallel file-open
# threads at startup. This option does not affect Direct IO; for that, see
# `rocksdb_direct_io`.
#
#rocksdb_optimize_for_spinning_disks = false
# Enables direct-io to increase database performance via unbuffered I/O.
#
# For more details about direct I/O and RockDB, see:
# https://github.com/facebook/rocksdb/wiki/Direct-IO
#
# Set this option to false if the database resides on a filesystem which
# does not support direct-io like FUSE, or any form of complex filesystem
# setup such as possibly ZFS.
#
#rocksdb_direct_io = true
# Amount of threads that RocksDB will use for parallelism on database
# operations such as cleanup, sync, flush, compaction, etc. Set to 0 to
# use all your logical threads. Defaults to your CPU logical thread count.
#
#rocksdb_parallelism_threads = varies by system
# Maximum number of LOG files RocksDB will keep. This must *not* be set to
# 0. It must be at least 1. Defaults to 3 as these are not very useful
# unless troubleshooting/debugging a RocksDB bug.
#
#rocksdb_max_log_files = 3
# Type of RocksDB database compression to use.
#
# Available options are "zstd", "bz2", "lz4", or "none".
#
# It is best to use ZSTD as an overall good balance between
# speed/performance, storage, IO amplification, and CPU usage. For more
# performance but less compression (more storage used) and less CPU usage,
# use LZ4.
#
# For more details, see:
# https://github.com/facebook/rocksdb/wiki/Compression
#
# "none" will disable compression.
#
#rocksdb_compression_algo = "zstd"
# Level of compression the specified compression algorithm for RocksDB to
# use.
#
# Default is 32767, which is internally read by RocksDB as the default
# magic number and translated to the library's default compression level
# as they all differ. See their `kDefaultCompressionLevel`.
#
# Note when using the default value we may override it with a setting
# tailored specifically tuwunel.
#
#rocksdb_compression_level = 32767
# Level of compression the specified compression algorithm for the
# bottommost level/data for RocksDB to use. Default is 32767, which is
# internally read by RocksDB as the default magic number and translated to
# the library's default compression level as they all differ. See their
# `kDefaultCompressionLevel`.
#
# Since this is the bottommost level (generally old and least used data),
# it may be desirable to have a very high compression level here as it's
# less likely for this data to be used. Research your chosen compression
# algorithm.
#
# Note when using the default value we may override it with a setting
# tailored specifically tuwunel.
#
#rocksdb_bottommost_compression_level = 32767
# Whether to enable RocksDB's "bottommost_compression".
#
# At the expense of more CPU usage, this will further compress the
# database to reduce more storage. It is recommended to use ZSTD
# compression with this for best compression results. This may be useful
# if you're trying to reduce storage usage from the database.
#
# See https://github.com/facebook/rocksdb/wiki/Compression for more details.
#
#rocksdb_bottommost_compression = true
# Database recovery mode (for RocksDB WAL corruption).
#
# Use this option when the server reports corruption and refuses to start.
# Set mode 2 (PointInTime) to cleanly recover from this corruption. The
# server will continue from the last good state, several seconds or
# minutes prior to the crash. Clients may have to run "clear-cache &
# reload" to account for the rollback. Upon success, you may reset the
# mode back to default and restart again. Please note in some cases the
# corruption error may not be cleared for at least 30 minutes of operation
# in PointInTime mode.
#
# As a very last ditch effort, if PointInTime does not fix or resolve
# anything, you can try mode 3 (SkipAnyCorruptedRecord) but this will
# leave the server in a potentially inconsistent state.
#
# The default mode 1 (TolerateCorruptedTailRecords) will automatically
# drop the last entry in the database if corrupted during shutdown, but
# nothing more. It is extraordinarily unlikely this will desynchronize
# clients. To disable any form of silent rollback set mode 0
# (AbsoluteConsistency).
#
# The options are:
# 0 = AbsoluteConsistency
# 1 = TolerateCorruptedTailRecords (default)
# 2 = PointInTime (use me if trying to recover)
# 3 = SkipAnyCorruptedRecord (you now voided your tuwunel warranty)
#
# For more information on these modes, see:
# https://github.com/facebook/rocksdb/wiki/WAL-Recovery-Modes
#
# For more details on recovering a corrupt database, see:
# https://tuwunel.chat/troubleshooting.html#database-corruption
#
#rocksdb_recovery_mode = 1
# Enables or disables paranoid SST file checks. This can improve RocksDB
# database consistency at a potential performance impact due to further
# safety checks ran.
#
# For more information, see:
# https://github.com/facebook/rocksdb/wiki/Online-Verification#columnfamilyoptionsparanoid_file_checks
#
#rocksdb_paranoid_file_checks = false
# Enables or disables checksum verification in rocksdb at runtime.
# Checksums are usually hardware accelerated with low overhead; they are
# enabled in rocksdb by default. Older or slower platforms may see gains
# from disabling.
#
#rocksdb_checksums = true
# Enables the "atomic flush" mode in rocksdb. This option is not intended
# for users. It may be removed or ignored in future versions. Atomic flush
# may be enabled by the paranoid to possibly improve database integrity at
# the cost of performance.
#
#rocksdb_atomic_flush = false
# Database repair mode (for RocksDB SST corruption).
#
# Use this option when the server reports corruption while running or
# panics. If the server refuses to start use the recovery mode options
# first. Corruption errors containing the acronym 'SST' which occur after
# startup will likely require this option.
#
# - Backing up your database directory is recommended prior to running the
# repair.
#
# - Disabling repair mode and restarting the server is recommended after
# running the repair.
#
# See https://tuwunel.chat/troubleshooting.html#database-corruption for more details on recovering a corrupt database.
#
#rocksdb_repair = false
# This item is undocumented. Please contribute documentation for it.
#
#rocksdb_read_only = false
# This item is undocumented. Please contribute documentation for it.
#
#rocksdb_secondary = false
# Enables idle CPU priority for compaction thread. This is not enabled by
# default to prevent compaction from falling too far behind on busy
# systems.
#
#rocksdb_compaction_prio_idle = false
# Enables idle IO priority for compaction thread. This prevents any
# unexpected lag in the server's operation and is usually a good idea.
# Enabled by default.
#
#rocksdb_compaction_ioprio_idle = true
# Enables RocksDB compaction. You should never ever have to set this
# option to false. If you for some reason find yourself needing to use
# this option as part of troubleshooting or a bug, please reach out to us
# in the tuwunel Matrix room with information and details.
#
# Disabling compaction will lead to a significantly bloated and
# explosively large database, gradually poor performance, unnecessarily
# excessive disk read/writes, and slower shutdowns and startups.
#
#rocksdb_compaction = true
# Level of statistics collection. Some admin commands to display database
# statistics may require this option to be set. Database performance may
# be impacted by higher settings.
#
# Option is a number ranging from 0 to 6:
# 0 = No statistics.
# 1 = No statistics in release mode (default).
# 2 to 3 = Statistics with no performance impact.
# 3 to 5 = Statistics with possible performance impact.
# 6 = All statistics.
#
#rocksdb_stats_level = 1
# Ignores the list of dropped columns set by developers.
#
# This should be set to true when knowingly moving between versions in
# ways which are not recommended or otherwise forbidden, or for
# diagnostic and development purposes; requiring preservation across such
# movements.
#
# The developer's list of dropped columns is meant to safely reduce space
# by erasing data no longer in use. If this is set to true that storage
# will not be reclaimed as intended.
#
#rocksdb_never_drop_columns = false
# Configures RocksDB to not preallocate WAL logs.
#
# Normally, RocksDB allocates certain types of files by calling
# fallocate, writing the file contents, then truncating the logs to the
# proper size. This causes pathological disk space usage on btrfs due to
# how it interacts with its Copy-on-Write implementation. On ZFS,
# fallocate(2) for preallocation is unsupported and returns EOPNOTSUPP;
# only `FALLOC_FL_PUNCH_HOLE` and `FALLOC_FL_ZERO_RANGE` are implemented.
#
# Set this to false if you run the server on btrfs or ZFS, and do not
# touch it otherwise.
#
#rocksdb_allow_fallocate = true
# This is a password that can be configured that will let you login to the
# server bot account (currently `@conduit`) for emergency troubleshooting
# purposes such as recovering/recreating your admin room, or inviting
# yourself back.
#
# See https://tuwunel.chat/troubleshooting.html#lost-access-to-admin-room
# for other ways to get back into your admin room.
#
# Once this password is unset, all sessions will be logged out for
# security purposes.
#
# example: "F670$2CP@Hw8mG7RY1$%!#Ic7YA"
#
#emergency_password =
# This item is undocumented. Please contribute documentation for it.
# reloadable: yes
#
#notification_push_path = "/_matrix/push/v1/notify"
# For compatibility and special purpose use only. Setting this option to
# true will not filter messages sent to pushers based on rules or actions.
# Everything will be sent to the pusher. This option is offered for
# several reasons, but should not be necessary:
# - Bypass to workaround bugs or outdated server-side ruleset support.
# - Allow clients to evaluate pushrules themselves (due to the above).
# - Hosting or companies which have custom pushers and internal needs.
#
# Note that setting this option to true will not affect the record of
# notifications found in the notifications pane.
# reloadable: yes
#
#push_everything = false
# Setting to false disables the heroes calculation made by sliding and
# legacy client sync. The heroes calculation is mandated by the Matrix
# specification and your client may not operate properly unless this
# option is set to true.
#
# This option is intended for custom software deployments seeking purely
# to minimize unused resources; the overall savings are otherwise
# negligible.
# reloadable: yes
#
#calculate_heroes = true
# Allow local (your server only) presence updates/requests.
#
# Note that presence on tuwunel is very fast unlike Synapse's. If using
# outgoing presence, this MUST be enabled.
# reloadable: yes
#
#allow_local_presence = true
# Allow incoming federated presence updates/requests.
#
# This option receives presence updates from other servers, but does not
# send any unless `allow_outgoing_presence` is true. Note that presence on
# tuwunel is very fast unlike Synapse's.
# reloadable: yes
#
#allow_incoming_presence = true
# Allow outgoing presence updates/requests.
#
# This option sends presence updates to other servers, but does not
# receive any unless `allow_incoming_presence` is true. Note that presence
# on tuwunel is very fast unlike Synapse's. If using outgoing presence,
# you MUST enable `allow_local_presence` as well.
# reloadable: yes
#
#allow_outgoing_presence = true
# How many seconds without presence updates before you become idle.
# Defaults to 5 minutes.
#
#presence_idle_timeout_s = 300
# How many seconds without presence updates before you become offline.
# Defaults to 30 minutes.
#
#presence_offline_timeout_s = 1800
# Enable the presence idle timer for remote users.
#
# Disabling is offered as an optimization for servers participating in
# many large rooms or when resources are limited. Disabling it may cause
# incorrect presence states (i.e. stuck online) to be seen for some remote
# users.
#
#presence_timeout_remote_users = true
# Suppresses push notifications for users marked as active. (Experimental)
#
# When enabled, users with `Online` presence and recent activity
# (based on presence state and sync activity) won’t receive push
# notifications, reducing duplicate alerts while they're active
# on another client.
#
# Disabled by default to preserve legacy behavior.
# reloadable: yes
#
#suppress_push_when_active = false
# Allow receiving incoming read receipts from remote servers.
# reloadable: yes
#
#allow_incoming_read_receipts = true
# Allow sending read receipts to remote servers.
# reloadable: yes
#
#allow_outgoing_read_receipts = true
# Allow outgoing typing updates to federation.
# reloadable: yes
#
#allow_outgoing_typing = true
# Allow incoming typing updates from federation.
# reloadable: yes
#
#allow_incoming_typing = true
# Maximum time federation user can indicate typing.
#
# reloadable: yes
#
#typing_federation_timeout_s = 30
# Minimum time local client can indicate typing. This does not override a
# client's request to stop typing. It only enforces a minimum value in
# case of no stop request.
#
# reloadable: yes
#
#typing_client_timeout_min_s = 15
# Maximum time local client can indicate typing.
#
# reloadable: yes
#
#typing_client_timeout_max_s = 45
# Set this to true for tuwunel to compress HTTP response bodies using
# zstd. This option does nothing if tuwunel was not built with
# `zstd_compression` feature. Please be aware that enabling HTTP
# compression may weaken TLS. Most users should not need to enable this.
# See https://breachattack.com/ and https://wikipedia.org/wiki/BREACH
# before deciding to enable this.
#
#zstd_compression = false
# Set this to true for tuwunel to compress HTTP response bodies using
# gzip. This option does nothing if tuwunel was not built with
# `gzip_compression` feature. Please be aware that enabling HTTP
# compression may weaken TLS. Most users should not need to enable this.
# See https://breachattack.com/ and https://wikipedia.org/wiki/BREACH before
# deciding to enable this.
#
# If you are in a large amount of rooms, you may find that enabling this
# is necessary to reduce the significantly large response bodies.
#
#gzip_compression = false
# Set this to true for tuwunel to compress HTTP response bodies using
# brotli. This option does nothing if tuwunel was not built with
# `brotli_compression` feature. Please be aware that enabling HTTP
# compression may weaken TLS. Most users should not need to enable this.
# See https://breachattack.com/ and https://wikipedia.org/wiki/BREACH
# before deciding to enable this.
#
#brotli_compression = false
# Set to true to allow user type "guest" registrations. Some clients like
# Element attempt to register guest users automatically.
# reloadable: yes
#
#allow_guest_registration = false
# Set to true to log guest registrations in the admin room. Note that
# these may be noisy or unnecessary if you're a public homeserver.
# reloadable: yes
#
#log_guest_registrations = false
# Set to true to allow guest registrations/users to auto join any rooms
# specified in `auto_join_rooms`.
# reloadable: yes
#
#allow_guests_auto_join_rooms = false
# Enable the legacy unauthenticated Matrix media repository endpoints.
# These endpoints consist of:
# - /_matrix/media/*/config
# - /_matrix/media/*/upload
# - /_matrix/media/*/preview_url
# - /_matrix/media/*/download/*
# - /_matrix/media/*/thumbnail/*
#
# The authenticated equivalent endpoints are always enabled.
#
# Defaults to false.
#
#allow_legacy_media = false
# Fallback to requesting legacy unauthenticated media from remote servers.
# Unauthenticated media was removed in ~2024Q3; enabling this adds
# considerable federation requests which are unlikely to succeed.
# reloadable: yes
#
#request_legacy_media = false
# This item is undocumented. Please contribute documentation for it.
# reloadable: yes
#
#freeze_legacy_media = true
# Check consistency of the media directory at startup:
# 1. When `media_compat_file_link` is enabled, this check will upgrade
# media when switching back and forth between Conduit and tuwunel. Both
# options must be enabled to handle this.
# 2. When media is deleted from the directory, this check will also delete
# its database entry.
#
# If none of these checks apply to your use cases, and your media
# directory is significantly large setting this to false may reduce
# startup time.
#
#media_startup_check = true
# Enable backward-compatibility with Conduit's media directory by creating
# symlinks of media.
#
# This option is only necessary if you plan on using Conduit again.
# Otherwise setting this to false reduces filesystem clutter and overhead
# for managing these symlinks in the directory. This is now disabled by
# default. You may still return to upstream Conduit but you have to run
# tuwunel at least once with this set to true and allow the
# media_startup_check to take place before shutting down to return to
# Conduit.
#
#media_compat_file_link = false
# Prune missing media from the database as part of the media startup
# checks.
#
# This means if you delete files from the media directory the
# corresponding entries will be removed from the database. This is
# disabled by default because if the media directory is accidentally moved
# or inaccessible, the metadata entries in the database will be lost with
# sadness.
#
#prune_missing_media = false
# List of storage providers to use for media. Providers can be configured
# below in respective sections designated by
# `global.storage_provider.<NAME>.<brand>` where `NAME` can be listed
# here.
#
# For advanced features and future extensions involving multiple providers
# the list may contain multiple entries. You MUST take note of other
# configuration options when listing multiple providers or resource
# duplication costs and poor performance can result.
#
# The list defaults to `["media"]` which is an implicit storage provider
# representing the media directory on the local filesystem. It can be
# altered by configuring `global.storage_provider.media.local` explicitly
# or disabled by omitting it from this list entirely. Users with existing
# deployments are advised to continue listing "media" as a fallback along
# with their new provider.
#
# reloadable: yes
#
#media_storage_providers = ["media"]
# List of configured storage providers where new media will be sent. When
# this list is not explicitly configured all entries in
# `media_storage_providers` are used as default.
#
# This list is important for users passively migrating to a new media
# storage provider by only writing to one while querying the other as a
# fallback.
#
# For example:
#
# `media_storage_providers = ["media", "media_on_s3"]`
# `store_media_on_providers = ["media_on_s3"]`
#
# Entries in this list must also be listed in `media_storage_providers`.
#
# reloadable: yes
#
#store_media_on_providers = []
# Vector list of regex patterns of server names that tuwunel will refuse
# to download remote media from.
#
# reloadable: yes
# example: ["badserver\.tld$", "badphrase", "19dollarfortnitecards"]
#
#prevent_media_downloads_from = []
# List of forbidden server names via regex patterns that we will block
# incoming AND outgoing federation with, and block client room joins /
# remote user invites.
#
# This check is applied on the room ID, room alias, sender server name,
# sender user's server name, inbound federation X-Matrix origin, and
# outbound federation handler.
#
# Basically "global" ACLs.
#
# reloadable: yes
# example: ["badserver\.tld$", "badphrase", "19dollarfortnitecards"]
#
#forbidden_remote_server_names = []
# (EXPERIMENTAL) The behavior of this option will change; the
# _experimental suffix will be removed for that change in an upcoming
# release.
#
# List of allowed server names via regex patterns. This is an allow-list
# rather than a deny-list with all the same details as its counterpart in
# `forbidden_remote_server_names`.
#
# This feature becomes active when this list has one or more entries;
# everything not matching is denied. By default it is empty and inactive.
#
# Entries in `forbidden_remote_server_names` are still applied after
# this is applied. This allows you to match e.g. "*\.example\.com" here
# while still singling out "bad\.example\.com" for exclusion.
#
# reloadable: yes
# example: ["badserver\.tld$", "badphrase", "19dollarfortnitecards"]
#
#allowed_remote_server_names_experimental = []
# List of forbidden server names via regex patterns that we will block all
# outgoing federated room directory requests for. Useful for preventing
# our users from wandering into bad servers or spaces.
#
# reloadable: yes
# example: ["badserver\.tld$", "badphrase", "19dollarfortnitecards"]
#
#forbidden_remote_room_directory_server_names = []
# Vector list of IPv4 and IPv6 CIDR ranges / subnets *in quotes* that you
# do not want tuwunel to send outbound requests to. Defaults to
# RFC1918, unroutable, loopback, multicast, and testnet addresses for
# security.
#
# Please be aware that this is *not* a guarantee. You should be using a
# firewall with zones as doing this on the application layer may have
# bypasses.
#
# Currently this does not account for proxies in use like Synapse does.
#
# To disable, set this to be an empty vector (`[]`).
#
# Defaults to:
# ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12",
# "192.168.0.0/16", "100.64.0.0/10", "192.0.0.0/24", "169.254.0.0/16",
# "192.88.99.0/24", "198.18.0.0/15", "192.0.2.0/24", "198.51.100.0/24",
# "203.0.113.0/24", "224.0.0.0/4", "::1/128", "fe80::/10", "fc00::/7",
# "2001:db8::/32", "ff00::/8", "fec0::/10"]
#
#ip_range_denylist =
# Optional IP address or network interface-name to bind as the source of
# URL preview requests. If not set, it will not bind to a specific
# address or interface.
#
# Interface names only supported on Linux, Android, and Fuchsia platforms;
# all other platforms can specify the IP address. To list the interfaces
# on your system, use the command `ip link show`.
#
# example: `"eth0"` or `"1.2.3.4"`
#
#url_preview_bound_interface =
# Vector list of domains allowed to send requests to for URL previews.
#
# This is a *contains* match, not an explicit match. Putting "google.com"
# will match "https://google.com" and
# "http://mymaliciousdomainexamplegoogle.com" Setting this to "*" will
# allow all URL previews. Please note that this opens up significant
# attack surface to your server, you are expected to be aware of the risks
# by doing so.
#
# reloadable: yes
#
#url_preview_domain_contains_allowlist = []
# Vector list of explicit domains allowed to send requests to for URL
# previews.
#
# This is an *explicit* match, not a contains match. Putting "google.com"
# will match "https://google.com", "http://google.com", but not
# "https://mymaliciousdomainexamplegoogle.com". Setting this to "*" will
# allow all URL previews. Please note that this opens up significant
# attack surface to your server, you are expected to be aware of the risks
# by doing so.
#
# reloadable: yes
#
#url_preview_domain_explicit_allowlist = []
# Vector list of explicit domains not allowed to send requests to for URL
# previews.
#
# This is an *explicit* match, not a contains match. Putting "google.com"
# will match "https://google.com", "http://google.com", but not
# "https://mymaliciousdomainexamplegoogle.com". The denylist is checked
# first before allowlist. Setting this to "*" will not do anything.
#
# reloadable: yes
#
#url_preview_domain_explicit_denylist = []
# Vector list of URLs allowed to send requests to for URL previews.
#
# Note that this is a *contains* match, not an explicit match. Putting
# "google.com" will match "https://google.com/",
# "https://google.com/url?q=https://mymaliciousdomainexample.com", and
# "https://mymaliciousdomainexample.com/hi/google.com" Setting this to "*"
# will allow all URL previews. Please note that this opens up significant
# attack surface to your server, you are expected to be aware of the risks
# by doing so.
#
# reloadable: yes
#
#url_preview_url_contains_allowlist = []
# Maximum body size allowed when spidering a URL for previews. Accepts an
# integer byte count or a string with SI/IEC suffix such as "256 KB".
#
# reloadable: yes
#
#url_preview_max_spider_size = 256000
# Option to decide whether you would like to run the domain allowlist
# checks (contains and explicit) on the root domain or not. Does not apply
# to URL contains allowlist. Defaults to false.
#
# Example usecase: If this is enabled and you have "wikipedia.org" allowed
# in the explicit and/or contains domain allowlist, it will allow all
# subdomains under "wikipedia.org" such as "en.m.wikipedia.org" as the
# root domain is checked and matched. Useful if the domain contains
# allowlist is still too broad for you but you still want to allow all the
# subdomains under a root domain.
# reloadable: yes
#
#url_preview_check_root_domain = false
# List of forbidden room aliases and room IDs as strings of regex
# patterns.
#
# Regex can be used or explicit contains matches can be done by just
# specifying the words (see example).
#
# This is checked upon room alias creation, custom room ID creation if
# used, and startup as warnings if any room aliases in your database have
# a forbidden room alias/ID.
#
# reloadable: yes
# example: ["19dollarfortnitecards", "b[4a]droom", "badphrase"]
#
#forbidden_alias_names = []
# List of forbidden username patterns/strings.
#
# Regex can be used or explicit contains matches can be done by just
# specifying the words (see example).
#
# This is checked upon username availability check, registration, and
# startup as warnings if any local users in your database have a forbidden
# username.
#
# reloadable: yes
# example: ["administrator", "b[a4]dusernam[3e]", "badphrase"]
#
#forbidden_usernames = []
# List of server names to deprioritize joining through.
#
# If a client requests a join through one of these servers,
# they will be tried last.
#
# Useful for preventing failed joins due to timeouts
# from a certain homeserver.
#
# reloadable: yes
#
#deprioritize_joins_through_servers = ["matrix\.org"]
# Maximum make_join requests to attempt within each join attempt. Each
# attempt tries a different server, as each server is only tried once;
# though retries can occur when the join request as a whole is retried.
#
# reloadable: yes
#
#max_make_join_attempts_per_join_attempt = 48
# Maximum join attempts to conduct per client join request. Each join
# attempt consists of one or more make_join requests limited above, and a
# single send_join request. This value allows for additional servers to
# act as the join-server prior to reporting the last error back to the
# client, which can be frustrating for users. Therefor the default value
# is greater than one, but less than excessively exceeding the client's
# request timeout, though that may not be avoidable in some cases.
#
# reloadable: yes
#
#max_join_attempts_per_join_request = 3
# Retry failed and incomplete messages to remote servers immediately upon
# startup. This is called bursting. If this is disabled, said messages may
# not be delivered until more messages are queued for that server. Do not
# change this option unless server resources are extremely limited or the
# scale of the server's deployment is huge. Do not disable this unless you
# know what you are doing.
#
#startup_netburst = true
# Messages are dropped and not reattempted. The `startup_netburst` option
# must be enabled for this value to have any effect. Do not change this
# value unless you know what you are doing. Set this value to -1 to
# reattempt every message without trimming the queues; this may consume
# significant disk. Set this value to 0 to drop all messages without any
# attempt at redelivery.
#
#startup_netburst_keep = 50
# Block non-admin local users from sending room invites (local and
# remote), and block non-admin users from receiving remote room invites.
#
# Admins are always allowed to send and receive all room invites.
# reloadable: yes
#
#block_non_admin_invites = false
# Allow admins to enter commands in rooms other than "#admins" (admin
# room) by prefixing your message with "\!admin" or "\\!admin" followed up
# a normal tuwunel admin command. The reply will be publicly visible to
# the room, originating from the sender.
#
# reloadable: yes
# example: \\!admin debug ping puppygock.gay
#
#admin_escape_commands = true
# Automatically activate the tuwunel admin room console / CLI on
# startup. This option can also be enabled with `--console` tuwunel
# argument.
#
#admin_console_automatic = false
# List of admin commands to execute on startup.
#
# This option can also be configured with the `--execute` tuwunel
# argument and can take standard shell commands and environment variables
#
# For example: `./tuwunel --execute "server admin-notice tuwunel has
# started up at $(date)"`
#
# example: admin_execute = ["debug ping puppygock.gay", "debug echo hi"]`
#
#admin_execute = []
# Ignore errors in startup commands.
#
# If false, tuwunel will error and fail to start if an admin execute
# command (`--execute` / `admin_execute`) fails.
# reloadable: yes
#
#admin_execute_errors_ignore = false
# List of admin commands to execute on SIGUSR2.
#
# Similar to admin_execute, but these commands are executed when the
# server receives SIGUSR2 on supporting platforms.
#
# reloadable: yes
#
#admin_signal_execute = []
# Controls the max log level for admin command log captures (logs
# generated from running admin commands). Defaults to "info" on release
# builds, else "debug" on debug builds.
#
# reloadable: yes
#
#admin_log_capture = "info"
# The default room tag to apply on the admin room.
#
# On some clients like Element, the room tag "m.server_notice" is a
# special pinned room at the very bottom of your room list. The tuwunel
# admin room can be pinned here so you always have an easy-to-access
# shortcut dedicated to your admin room.
#
# reloadable: yes
#
#admin_room_tag = "m.server_notice"
# Whether to grant the first user to register admin privileges by joining
# them to the admin room. Note that technically the next user to register
# when the admin room is empty (or only contains the server-user) is
# granted, and only when the admin room is enabled.
#
# reloadable: yes
#
#grant_admin_to_first_user = true
# Whether the admin room is created on first startup. Users should not set
# this to false. Developers can set this to false during integration tests
# to reduce activity and output.
#
#create_admin_room = true
# Whether to enable federation on the admin room. This cannot be changed
# after the admin room is created.
#
#federate_admin_room = true
# Sentry.io crash/panic reporting, performance monitoring/metrics, etc.
# This is NOT enabled by default. tuwunel's default Sentry reporting
# endpoint domain is `o4509498990067712.ingest.us.sentry.io`.
#
#sentry = false
# Sentry reporting URL, if a custom one is desired.
#
#sentry_endpoint = ""
# Report your tuwunel server_name in Sentry.io crash reports and
# metrics.
#
#sentry_send_server_name = false
# Performance monitoring/tracing sample rate for Sentry.io.
#
# Note that too high values may impact performance, and can be disabled by
# setting it to 0.0 (0%) This value is read as a percentage to Sentry,
# represented as a decimal. Defaults to 15% of traces (0.15)
#
#sentry_traces_sample_rate = 0.15
# Whether to attach a stacktrace to Sentry reports.
#
#sentry_attach_stacktrace = false
# Send panics to Sentry. This is true by default, but Sentry has to be
# enabled. The global `sentry` config option must be enabled to send any
# data.
#
#sentry_send_panic = true
# Send errors to sentry. This is true by default, but sentry has to be
# enabled. This option is only effective in release-mode; forced to false
# in debug-mode.
#
#sentry_send_error = true
# Controls the tracing log level for Sentry to send things like
# breadcrumbs and transactions
#
#sentry_filter = "info"
# Enable the tokio-console. This option is only relevant to developers.
#
# For more information, see:
# https://tuwunel.chat/development.html#debugging-with-tokio-console
#
#tokio_console = false
# Arbitrary argument vector for integration testing. Functionality in the
# server is altered or informed for the requirements of integration tests.
# - "smoke" performs a shutdown after startup admin commands rather than
# hanging on client handling.
#
#test = false
# Indicates the server has started in maintenance mode. Historically
# maintenance mode has been enabled by the command line argument
# `--maintenance` which then sets various configuration items such as
# `listening=false` among others. That is still the case. This option was
# only added as a single source of truth that `--maintenance` mode is
# active.
#
# This option must never be set manually.
#
#maintenance = false
# Controls whether admin room notices like account registrations, password
# changes, account deactivations, room directory publications, etc will be
# sent to the admin room. Update notices and normal admin command
# responses will still be sent.
# reloadable: yes
#
#admin_room_notices = true
# Save original events before applying redaction to them.
#
# They can be retrieved with `admin debug get-retained-pdu` or MSC2815.
#
# reloadable: yes
#
#save_unredacted_events = true
# Redaction retention period in seconds.
#
# By default the unredacted events are stored for 60 days.
#
# reloadable: yes
#
#redaction_retention_seconds = 5184000
# Allows users with `redact` power level to request unredacted events with
# MSC2815.
#
# Server admins can request unredacted events regardless of the value of
# this option.
#
# reloadable: yes
#
#allow_room_admins_to_request_unredacted_events = true
# Prevents local users from sending redactions.
#
# This check does not apply to server admins.
# reloadable: yes
#
#disable_local_redactions = false
# Enable database pool affinity support. On supporting systems, block
# device queue topologies are detected and the request pool is optimized
# for the hardware; db_pool_workers is determined automatically.
#
#db_pool_affinity = true
# Sets the number of worker threads in the frontend-pool of the database.
# This number should reflect the I/O capabilities of the system,
# such as the queue-depth or the number of simultaneous requests in
# flight. Defaults to 32 times the number of CPU cores.
#
# Note: This value is only used if db_pool_affinity is disabled or not
# detected on the system, otherwise it is determined automatically.
#
#db_pool_workers = 32
# When db_pool_affinity is enabled and detected, the size of any worker
# group will not exceed the determined value. This is necessary when
# thread-pooling approach does not scale to the full capabilities of
# high-end hardware; using detected values without limitation could
# degrade performance.
#
# The value is multiplied by the number of cores which share a device
# queue, since group workers can be scheduled on any of those cores.
#
#db_pool_workers_limit = 64
# Limits the total number of workers across all worker groups. When the
# sum of all groups exceeds this value the worker counts are reduced until
# this constraint is satisfied.
#
# By default this value is only effective on larger systems (e.g. 16+
# cores) where it will tamper the overall thread-count. The thread-pool
# model will never achieve hardware capacity but this value can be raised
# on huge systems if the scheduling overhead is determined to not
# bottleneck and the worker groups are divided too small.
#
#db_pool_max_workers = 2048
# Determines the size of the queues feeding the database's frontend-pool.
# The size of the queue is determined by multiplying this value with the
# number of pool workers. When this queue is full, tokio tasks conducting
# requests will yield until space is available; this is good for
# flow-control by avoiding buffer-bloat, but can inhibit throughput if
# too low.
#
#db_pool_queue_mult = 4
# Sets the initial value for the concurrency of streams. This value simply
# allows overriding the default in the code. The default is 32, which is
# the same as the default in the code. Note this value is itself
# overridden by the computed stream_width_scale, unless that is disabled;
# this value can serve as a fixed-width instead.
#
#stream_width_default = 32
# Scales the stream width starting from a base value detected for the
# specific system. The base value is the database pool worker count
# determined from the hardware queue size (e.g. 32 for SSD or 64 or 128+
# for NVMe). This float allows scaling the width up or down by multiplying
# it (e.g. 1.5, 2.0, etc). The maximum result can be the size of the pool
# queue (see: db_pool_queue_mult) as any larger value will stall the tokio
# task. The value can also be scaled down (e.g. 0.5) to improve
# responsiveness for many users at the cost of throughput for each.
#
# Setting this value to 0.0 causes the stream width to be fixed at the
# value of stream_width_default. The default scale is 1.0 to match the
# capabilities detected for the system.
#
#stream_width_scale = 1.0
# Sets the initial amplification factor. This controls batch sizes of
# requests made by each pool worker, multiplying the throughput of each
# stream. This value is somewhat abstract from specific hardware
# characteristics and can be significantly larger than any thread count or
# queue size. This is because each database query may require several
# index lookups, thus many database queries in a batch may make progress
# independently while also sharing index and data blocks which may or may
# not be cached. It is worthwhile to submit huge batches to reduce
# complexity. The maximum value is 32768, though sufficient hardware is
# still advised for that.
#
#stream_amplification = 1024
# Number of sender task workers; determines sender parallelism. Default is
# '0' which means the value is determined internally, likely matching the
# number of tokio worker-threads or number of cores, etc. Override by
# setting a non-zero value.
#
#sender_workers = 0
# Enables listener sockets; can be set to false to disable listening. This
# option is intended for developer/diagnostic purposes only.
#
#listening = true
# Enables configuration reload when the server receives SIGUSR1 on
# supporting platforms.
#
# reloadable: yes
#
#config_reload_signal = true
# Sets the `Access-Control-Allow-Origin` header included by this server in
# all responses. A list of multiple values can be specified. The default
# is an empty list. The actual header defaults to `*` upon an empty list.
#
# There is no reason to configure this without specific intent. Incorrect
# values may degrade or disrupt clients.
#
#access_control_allow_origin = []
# Backport state-reset security fixes to all room versions.
#
# This option applies the State Resolution 2.1 mitigation developed during
# project Hydra for room version 12 to all prior State Resolution 2.0 room
# versions (all room versions supported by this server). These mitigations
# increase resilience to state-resets without any new definition of
# correctness; therefor it is safe to set this to true for existing rooms.
#
# Furthermore, state-reset attacks are not consistent as they result in
# rooms without any single consensus, therefor it is unnecessary to set
# this to false to match other servers which set this to false or simply
# lack support; even if replicating the post-reset state suffered by other
# servers is somehow desired.
#
# This option exists for developer and debug use, and as a failsafe in
# lieu of hardcoding it.
# reloadable: yes
#
#hydra_backports = true
# Delete rooms when the last user from this server leaves. This feature is
# experimental and for the purpose of least-surprise is not enabled by
# default but can be enabled for deployments interested in conserving
# space. It may eventually default to true in a future release.
#
# Note that not all pathways which can remove the last local user
# currently invoke this operation, so in some cases you may find the room
# still exists.
#
# reloadable: yes
#
#delete_rooms_after_leave = false
# Limits the number of One Time Keys per device (not per-algorithm). The
# reference implementation maintains 50 OTK's at any given time, therefor
# our default is at least five times that. There is no known reason for an
# administrator to adjust this value; it is provided here rather than
# hardcoding it.
#
# reloadable: yes
#
#one_time_key_limit = 256
# (EXPERIMENTAL) Setting this option to true replaces the list of identity
# providers displayed on a client's login page with a single button "Sign
# in with single sign-on" linking to the URL
# `/_matrix/client/v3/login/sso/redirect`. All configured providers are
# attempted for authorization. All authorizations associate with the same
# Matrix user. NOTE: All authorizations must succeed, as there is no
# reliable way to skip a provider.
#
# This option is disabled by default, allowing the client to list
# configured providers and permitting privacy-conscious users to authorize
# only their choice.
#
# Note that fluffychat always displays a single button anyway. You do not
# need to enable this to use fluffychat; instead we offer a
# default-provider option, see `default` in the provider config section.
# reloadable: yes
#
#single_sso = false
# Setting this option to true replaces the list of identity providers on
# the client's login screen with a single button "Sign in with single
# sign-on" linking to the URL `/_matrix/client/v3/login/sso/redirect`. The
# deployment is expected to intercept this URL with their reverse-proxy to
# provide a custom webpage listing providers; each entry linking or
# redirecting back to one of the configured identity providers at
# /_matrix/client/v3/login/sso/redirect/<client_id>`.
#
# This option defaults to false, allowing the client to generate the list
# of providers or hide all SSO-related options when none configured.
# reloadable: yes
#
#sso_custom_providers_page = false
# From MSC3824:
# > If the client finds oauth_aware_preferred to be true then, assuming it
# > supports that auth type, it should present this as the only
# > login/registration method available to the user.
# reloadable: yes
#
#oidc_aware_preferred = false
# Directory containing appservice yaml registration files.
#
#appservice_dir = ""
# Skip database migration on startup. This option is intended for
# developer debugging and testing only. Never set this option to false
# unless you have been instructed to do so. Setting this option to false
# may cause permanent damage and permanent loss of data.
#
# Any new database migrations will not be applied on startup, and the
# database schema version will not be adjusted. These migrations and
# schema changes may be expected by the current codebase but may not be
# available when this option is set to false.
#
# Setting this option to false will have no effect if no new migrations
# are to be applied. New migrations are applied once during any execution
# where this option is set to true (which is the default).
#
#database_migrations = true
# Force the database to set its version to the current version known to
# the executable.
#
# - When the discovered version is less than the current version any
# migrations are applied normally.
# - When the discovered version is equal to the current version,
# unversioned migrations are applied normally.
# - When the discovered database version is greater than the current
# version, one-time migrations are applied normally and the discoverable
# version is regressed back to the current version.
#
# This option extremely dangerous and intended for developer debugging and
# testing only. Never set this option unless you have been instructed to
# do so. Setting this option may cause permanent damage and permanent loss
# of data.
#
#force_migration = false
# Set this to true for excluding unencrypted rooms from the common-rooms
# calculation deciding the receivers of device list updates.
#
# Setting this to true can help performance on very large homeservers,
# but it may not be spec compliant and risky for client expectations.
# reloadable: yes
#
#device_key_update_encrypted_rooms_only = false
#[global.tls]
# Path to a valid TLS certificate file.
#
# example: "/path/to/my/certificate.crt"
#
#certs =
# Path to a valid TLS certificate private key.
#
# example: "/path/to/my/certificate.key"
#
#key =
# Whether to listen and allow for HTTP and HTTPS connections (insecure!)
#
#dual_protocol = false
#[global.well_known]
# The server URL that the client well-known file will serve. This should
# not contain a port, and should just be a valid HTTPS URL.
#
# example: "https://matrix.example.com"
#
#client =
# The server base domain of the URL with a specific port that the server
# well-known file will serve. This should contain a port at the end, and
# should not be a URL.
#
# reloadable: yes
# example: "matrix.example.com:443"
#
#server =
# The URL of the support web page. This and the below generate the content
# of `/.well-known/matrix/support`.
#
# reloadable: yes
# example: "https://example.com/support"
#
#support_page =
# The name of the support role.
#
# reloadable: yes
# example: "m.role.admin"
#
#support_role =
# The email address for the above support role.
#
# reloadable: yes
# example: "admin@example.com"
#
#support_email =
# The Matrix User ID for the above support role.
#
# example "@admin:example.com"
# reloadable: yes
#
#support_mxid =
# LiveKit JWT endpoint.
# Required for Element Call / MatrixRTC (MSC4143).
#
# Note: You must also set `client` above to your homeserver URL.
#
# reloadable: yes
#
#livekit_url = ""
# Custom MatrixRTC transports.
#
# If you're looking to setup Element Call / MatrixRTC with Livekit,
# you should not use this option and instead set `livekit_url`.
# This is only required if you want to configure a non-livekit MatrixRTC
# transport. There are no known client implementations that support any
# other transport types.
#
# This option was previously the only way to configure a Livekit
# transport. It has been superseded by `livekit_url`.
#
# Example:
# ```toml
# [global.well_known]
# client = "https://matrix.yourdomain.com"
#
# [[global.well_known.rtc_transports]]
# type = "livekit"
# livekit_service_url = "https://livekit.yourdomain.com"
# ```
#
# reloadable: yes
#
#rtc_transports = []
#[global.blurhashing]
# blurhashing x component, 4 is recommended by https://blurha.sh/
#
# reloadable: yes
#
#components_x = 4
# blurhashing y component, 3 is recommended by https://blurha.sh/
#
# reloadable: yes
#
#components_y = 3
# Max raw size that the server will blurhash, this is the size of the
# image after converting it to raw data, it should be higher than the
# upload limit but not too high. The higher it is the higher the
# potential load will be for clients requesting blurhashes. Accepts an
# integer byte count or a string with SI/IEC suffix such as "32 MiB".
# Setting it to 0 disables blurhashing.
#
# reloadable: yes
#
#blurhash_max_raw_size = 33554432
#[global.ldap]
# Whether to enable LDAP login.
#
# reloadable: yes
# example: "true"
#
#enable = false
# URI of the LDAP server.
#
# reloadable: yes
# example: "ldap://ldap.example.com:389"
#
#uri =
# Root of the searches.
#
# reloadable: yes
# example: "ou=users,dc=example,dc=org"
#
#base_dn =
# Bind DN if anonymous search is not enabled.
#
# You can use the variable `{username}` that will be replaced by the
# entered username. In such case, the password used to bind will be the
# one provided for the login and not the one given by
# `bind_password_file`. Beware: automatically granting admin rights will
# not work if you use this direct bind instead of a LDAP search.
#
# reloadable: yes
# example: "cn=ldap-reader,dc=example,dc=org" or
# "cn={username},ou=users,dc=example,dc=org"
#
#bind_dn = ""
# Path to a file on the system that contains the password for the
# `bind_dn`.
#
# The server must be able to access the file, and it must not be empty.
#
# reloadable: yes
#
#bind_password_file = ""
# Search filter to limit user searches.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# reloadable: yes
# example: "(&(objectClass=person)(memberOf=matrix))"
#
#filter = "(objectClass=*)"
# Attribute to use to uniquely identify the user.
#
# reloadable: yes
# example: "uid" or "cn"
#
#uid_attribute = "uid"
# Attribute containing the distinguished name of the user.
#
# reloadable: yes
# example: "givenName" or "sn"
#
#name_attribute = "givenName"
# Root of the searches for admin users.
#
# Defaults to `base_dn` if empty.
#
# reloadable: yes
# example: "ou=admins,dc=example,dc=org"
#
#admin_base_dn =
# The LDAP search filter to find administrative users for tuwunel.
#
# If left blank, administrative state must be configured manually for each
# user.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# reloadable: yes
# example: "(objectClass=tuwunelAdmin)" or "(uid={username})"
#
#admin_filter =
#[global.jwt]
# Enable JWT logins
#
# reloadable: yes
#
#enable = false
# Validation key, also called 'secret' in Synapse config. The type of key
# can be configured in 'format', but defaults to the common HMAC which
# is a plaintext shared-secret, so you should keep this value private.
#
# reloadable: yes
#
#key =
# Format of the 'key'. Only HMAC, ECDSA, and B64HMAC are supported
# Binary keys cannot be pasted into this config, so B64HMAC is an
# alternative to HMAC for properly random secret strings.
# - HMAC is a plaintext shared-secret private-key.
# - B64HMAC is a base64-encoded version of HMAC.
# - ECDSA is a PEM-encoded public-key.
# - EDDSA is a PEM-encoded Ed25519 public-key.
#
# reloadable: yes
#
#format = "HMAC"
# Automatically create new user from a valid claim, otherwise access is
# denied for an unknown even with an authentic token.
#
# reloadable: yes
#
#register_user = true
# JWT algorithm
#
# reloadable: yes
#
#algorithm = "HS256"
# Optional audience claim list. The token must claim one or more values
# from this list when set.
#
# reloadable: yes
#
#audience = []
# Optional issuer claim list. The token must claim one or more values
# from this list when set.
#
# reloadable: yes
#
#issuer = []
# Require expiration claim in the token. This defaults to false for
# synapse migration compatibility.
#
# reloadable: yes
#
#require_exp = false
# Require not-before claim in the token. This defaults to false for
# synapse migration compatibility.
#
# reloadable: yes
#
#require_nbf = false
# Validate expiration time of the token when present. Whether or not it is
# required depends on require_exp, but when present this ensures the token
# is not used after a time.
#
# reloadable: yes
#
#validate_exp = true
# Validate not-before time of the token when present. Whether or not it is
# required depends on require_nbf, but when present this ensures the token
# is not used before a time.
#
# reloadable: yes
#
#validate_nbf = true
# Bypass validation for diagnostic/debug use only.
#
# reloadable: yes
#
#validate_signature = true
#[[global.identity_provider]]
# The brand-name of the service (e.g. Apple, Facebook, GitHub, GitLab,
# Google) or the software (e.g. keycloak, MAS) providing the identity.
# When a brand is recognized we apply certain defaults to this config
# for your convenience. For certain brands we apply essential internal
# workarounds specific to that provider; it is important to configure this
# field properly when a provider needs to be recognized (like GitHub for
# example).
#
# Several configured providers can share the same brand name. It is not
# case-sensitive. As a convenience for common simple deployments we can
# identify this provider by brand in addition to the unique `client_id` if
# and only if there is a single provider for the brand; see notes for
# `client_id`.
#
#brand =
# The ID of your OAuth application which the provider generates upon
# registration. This ID then uniquely identifies this configuration
# instance itself, becoming the identity provider's ID and must be unique
# and remain unchanged.
#
# As a convenience we also identify this config by `brand` if and only if
# there is a single provider configured for a `brand`. Note carefully that
# multiple providers configured with the same `brand` is not an error and
# this provider will simply not be found when querying by `brand`.
#
#client_id =
# Secret key the provider generated for you along with the `client_id`
# above. Unlike the `client_id`, the `client_secret` can be changed here
# whenever the provider regenerates one for you.
#
#client_secret =
# Secret key to use that's read from the file path specified.
#
# This takes priority over "client_secret" first, and falls back to
# "client_secret" if invalid or failed to open.
#
# example: "/etc/tuwunel/.client_secret"
#
#client_secret_file =
# Issuer URL the provider publishes for you. We have pre-supplied default
# values for some of the canonical public providers, making this field
# optional based on the `brand` set above. Otherwise it is required to
# find self-hosted providers. It must be identical to what is configured
# and expected by the provider and must never change because we associate
# identities to it. If the `/.well-known/openid-configuration` is not
# found behind this URL see `base_path` below as a workaround.
#
#issuer_url =
# The callback URL configured when registering the OAuth application with
# the provider. Tuwunel's callback URL must be strictly formatted exactly
# as instructed. The URL host must point directly at the matrix server and
# use the following path:
# `/_matrix/client/unstable/login/sso/callback/<client_id>` where
# `<client_id>` is the same one configured for this provider above.
#
#callback_url =
# When more than one identity_provider has been configured and
# `single_sso` is false and `sso_custom_providers_page` is false this will
# determine the behavior of the `/_matrix/client/v3/login/sso/redirect`
# endpoint (note the url lacks a trailing `client_id`).
#
# When only one identity_provider is configured it will be interpreted
# as the default and this does not need to be set. Otherwise a default
# *must* be selected for some clients (e.g. fluffychat) to work properly
# when the above conditions require it. To operate out-of-the-box we
# default to one configured provider if none are explicitly default; a
# warning will be logged on startup for this condition.
#
# (EXPERIMENTAL) Multiple providers can be set to default. All providers
# configured with this option set to `true` will associate with the same
# Matrix account when a client flows through
# `/_matrix/client/v3/login/sso/redirect`.
#
# When a user authorizes any provider configured default, the flow will
# include all other providers configured default as well for association.
# NOTE: authorization must succeed for ALL default providers.
#
#default = false
# Optional display-name for this provider instance seen on the login page
# by users. It defaults to `brand`. When configuring multiple providers
# using the same `brand` this can be set to distinguish them.
#
#name =
# Optional icon for the provider. The canonical providers have a default
# icon based on the `brand` supplied above when this is not supplied. Note
# that it uses an MXC url which is curious in the auth-media era and may
# not be reliable.
#
#icon =
# Optional list of scopes to authorize. An empty array does not impose any
# restrictions from here, effectively defaulting to all scopes you
# configured for the OAuth application at the provider. This setting
# allows for restricting to a subset of those scopes for this instance.
# Note the user can further restrict scopes during their authorization.
#
#scope = []
# Optional list of userinfo claims which shape and restrict the way we
# compute a Matrix UserId for new registrations. Reviewing Tuwunel's
# documentation will be necessary for a complete description in detail. An
# empty array imposes no restriction here, avoiding generated fallbacks as
# much as possible.
#
# For simplicity we reserve a claim called "unique" which can be listed
# alone to ensure *only* generated ID's are used for registrations.
#
# Note that listing the claim "sub" has special significance and will take
# precedence over all other claims, listed or unlisted. "sub" is not
# normally used to determine a UserId unless explicitly listed here.
#
# As of now arbitrary claims cannot be listed here, we only recognize
# specific hard-coded claims.
#
#userid_claims = []
# Trusted providers can cause username conflicts (i.e. account hijacking)
# but this is precisely how an existing matrix account can be associated
# with a provider. When this option is set to true, the way we compute a
# Matrix UserId from userinfo claims is inverted: we find the first
# matching user and grant access to it. Whereas by default, when set to
# false, we skip matching users and register the first available username;
# falling-back to random characters to avoid conflicts.
#
# Only set this option to true for providers you self-host and control.
# Never set this option to true for the public providers such as GitHub,
# GitLab, etc.
#
# Note that associating an existing user with an untrusted provider is
# still possible but only with the command '!admin query oauth associate'.
#
#trusted = false
# Setting this option to false will inhibit unique ID's from being
# generated as a last-resort when determining a UserId from a provider's
# claims. In the case of untrusted providers, when all provided claims
# conflict with existing user accounts, a unique fallback ID needs
# to be generated for registration to not be denied with an error.
#
# Set this option to false if you operate a private server or a trusted
# identity provider where random UserId's are undesirable; the result of a
# misconfiguration or other issue where an error is warranted.
#
# This option should be set to true for public servers or some users may
# never be able to register.
#
#unique_id_fallbacks = true
# Controls whether new user registration is possible from this provider.
# When this option is set to false, authorizations from this provider
# only affect existing users and will never result in a new registration
# when the claims fail to match any existing user (in the case of trusted
# providers) or an available username is found (in the case of untrusted
# providers).
#
# Setting this option to false is generally not useful unless there is
# an explicit reason to do so.
#
#registration = true
# Optional extra path components after the issuer_url leading to the
# location of the `.well-known` directory used for discovery. If the path
# starts with a slash it will be treated as absolute, meaning overwriting
# any path in the issuer_url. The path needs to end with a slash. This
# will be empty for specification-compliant providers. We have supplied
# any known values based on `brand` (e.g. `login/oauth/` for GitHub).
#
#base_path =
# Overrides the `.well-known` location where the provider's openid
# configuration is found. It is very unlikely you will need to set this;
# available for developers or special purposes only.
#
#discovery_url =
# Overrides the authorize URL requested during the grant phase. This is
# generally discovered or derived automatically, but may be required as a
# workaround for any non-standard or undiscoverable provider.
#
#authorization_url =
# Overrides the access token URL; the same caveats apply as with the other
# URL overrides.
#
#token_url =
# Overrides the revocation URL; the same caveats apply as with the other
# URL overrides.
#
#revocation_url =
# Overrides the introspection URL; the same caveats apply as with the
# other URL overrides.
#
#introspection_url =
# Overrides the userinfo URL; the same caveats apply as with the other URL
# overrides.
#
#userinfo_url =
# Whether to perform discovery and adjust this provider's configuration
# accordingly. This defaults to true. When true, it is an error when
# discovery fails and authorizations will not be attempted to the
# provider.
#
#discovery = true
# The duration in seconds before a grant authorization session expires.
#
#grant_session_duration = 300
# Whether to check the redirect cookie during the callback. This is a
# security feature and should remain enabled. This is available for
# developers or deployments which cannot tolerate cookies and are willing
# to tolerate the risks.
#
#check_cookie = true
# Extra query parameters appended to every authorization request sent to
# the identity provider.
#
# E.g. to force re-authentication even if IdP cookies are present:
# ```toml
# [[global.identity_provider]]
# extra_authorization_parameters = { prompt = "login" }
# ```
#
#extra_authorization_parameters = {}
#[global.storage_provider.<ID>.local]
# Absolute path to this local filesystem storage provider. Technically the
# provider exists at the filesystem root, and the base_path is prefixed to
# all objects.
#
#base_path =
# Creates the directory on the local filesystem if missing. This is not
# recommended to prevent misconfigured environments and missing mounts
# from silently succeeding.
#
#create_if_missing = false
# Toggles the preservation of a directory after its last file contents are
# removed.
#
#delete_empty_directories = true
# Enables checks performed at startup determining the usability of the
# local directory. Failures will abort the server's startup.
#
#startup_check = true
#[global.storage_provider.<ID>.s3]
# Supply an s3 URL e.g. "s3://bucket/path". These URLs may contain one
# or all of `bucket`, `region`, and `path` . When not supplied, such
# additional items can be supplied below individually.
#
#url =
# The name of the S3 bucket. e.g. "bucketname-123456789-us-west-2-an".
#
#bucket =
# The region of the S3 bucket. e.g. "us-west-2".
#
#region = "us-east-1"
# Your amazon IAM Key ID with access granted to this bucket.
# e.g. "ABCDEFG1X1ZZYYXXWWVV"
#
#key =
# The secret key component which is approx 40 characters of base64.
#
#secret =
# Optional path prefix within the bucket where all our operations will
# take place.
#
#base_path =
# (expert use) Override the location of s3 applied after components of the
# parsed `url` (or when none set).
#
#endpoint =
# (expert use) Override this property useful for some self-hosted
# environments. By default it is derived when parsing the primary `url`.
#
#use_vhost_request = false
# (expert use) Alternative session-token authentication method.
#
#token =
# (expert use) Associated SSE-KMS key material.
#
#kms =
# (expert use) When configured for the bucket it should be reflected here.
#
#use_bucket_key =
# (expert use) Threshold size for switching to Multi-part uploads. This is
# a quirk of the S3 protocol which requires us to use a different approach
# for "large" uploads. This value determines what a "large" upload is. The
# default value should be sufficient for most providers. The value is a
# parsed string allowing SI or IEC units for convenience.
#
#multipart_threshold = 100 MiB
# (expert use) Size of each individual part within a Multi-part upload.
# Once an upload exceeds `multipart_threshold` the payload is split into
# parts of this size, each sent as a separate HTTP PUT. Smaller values
# keep individual requests under per-request timeouts on slow uplinks at
# the cost of more round-trips. S3 requires every part except the last
# to be at least 5 MiB. The value is a parsed string allowing SI or IEC
# units for convenience.
#
#multipart_part_size = 10 MiB
# (developer use) Allows relaxing default requirement forcing HTTPS.
#
#use_https = true
# (developer_use) Allows skipping request header signatures (will be
# reejected by AWS).
#
#use_signatures = true
# (developer_use) Allows disabling request payload signatures.
#
#use_payload_signatures = true
# (developer use) Enables checks performed at startup such as pinging the
# provider. Failures are considered critical startup errors which abort
# startup. When set to false, faulty providers are only discovered with
# first use and will not be fatal errors.
#
# Only set this to false if you expect a provider to be down at startup or
# for development/testing purposes; checks are disabled when the server
# is started in '--maintenance' mode.
#
#startup_check = true
#[global.appservice.<ID>]
# The URL for the application service.
#
# Optionally set to `null` if no traffic is required.
#
#url =
# A unique token for application services to use to authenticate requests
# to Homeservers.
#
#as_token =
# A unique token for Homeservers to use to authenticate requests to
# application services.
#
#hs_token =
# The localpart of the user associated with the application service.
#
#sender_localpart =
# Whether requests from masqueraded users are rate-limited.
#
# The sender is excluded.
#
#rate_limited = false
# The external protocols which the application service provides (e.g.
# IRC).
#
#protocols = []
# Whether the application service wants to receive ephemeral data.
#
#receive_ephemeral = false
# Whether the application service wants to do device management, as part
# of MSC4190.
#
#device_management = false
#[[global.appservice.<ID>.<users|rooms|aliases>]]
# Whether this application service has exclusive access to events within
# this namespace.
#
#exclusive = false
# A regular expression defining which values this namespace includes.
#
#regex =
Debian systemd unit file
Debian systemd unit file
[Unit]
Description=Tuwunel Matrix homeserver
Wants=network-online.target
After=network-online.target
Documentation=https://tuwunel.chat/
[Service]
User=tuwunel
Group=tuwunel
Type=notify
WatchdogSec=30
Environment="TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml"
ExecStart=/usr/sbin/tuwunel
ReadWritePaths=/var/lib/tuwunel /etc/tuwunel
AmbientCapabilities=
CapabilityBoundingSet=
ManagedOOMPreference=avoid
DevicePolicy=closed
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
#ProcSubset=pid
ProtectClock=yes
ProtectControlGroups=yes
ProtectHome=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
ProtectSystem=strict
PrivateDevices=yes
PrivateMounts=yes
PrivateTmp=yes
PrivateUsers=yes
PrivateIPC=yes
RemoveIPC=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service @resources
SystemCallFilter=~@clock @debug @module @mount @reboot @swap @cpu-emulation @obsolete @timer @chown @setuid @privileged @keyring @ipc
SystemCallErrorNumber=EPERM
#StateDirectory=tuwunel
RuntimeDirectory=tuwunel
RuntimeDirectoryMode=0750
Restart=on-failure
RestartSec=5
TimeoutStopSec=2m
TimeoutStartSec=2m
StartLimitInterval=1m
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
Alias=matrix-tuwunel.service
Arch Linux systemd unit file
Arch Linux systemd unit file
[Unit]
Description=Tuwunel Matrix homeserver
Wants=network-online.target
After=network-online.target
Documentation=https://tuwunel.chat/
RequiresMountsFor=/var/lib/private/tuwunel
[Service]
DynamicUser=yes
Type=notify-reload
ReloadSignal=SIGUSR1
WatchdogSec=30
TTYPath=/dev/tty25
DeviceAllow=char-tty
StandardInput=tty-force
StandardOutput=tty
StandardError=journal+console
TTYReset=yes
# uncomment to allow buffer to be cleared every restart
TTYVTDisallocate=no
TTYColumns=120
TTYRows=40
AmbientCapabilities=
CapabilityBoundingSet=
ManagedOOMPreference=avoid
DevicePolicy=closed
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
#ProcSubset=pid
ProtectClock=yes
ProtectControlGroups=yes
ProtectHome=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
ProtectSystem=strict
PrivateDevices=yes
PrivateMounts=yes
PrivateTmp=yes
PrivateUsers=yes
PrivateIPC=yes
RemoveIPC=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service @resources
SystemCallFilter=~@clock @debug @module @mount @reboot @swap @cpu-emulation @obsolete @timer @chown @setuid @privileged @keyring @ipc
SystemCallErrorNumber=EPERM
StateDirectory=tuwunel
RuntimeDirectory=tuwunel
RuntimeDirectoryMode=0750
Environment="TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml"
BindPaths=/var/lib/private/tuwunel:/var/lib/conduwuit
BindPaths=/var/lib/private/tuwunel:/var/lib/private/conduwuit
BindPaths=/var/lib/private/tuwunel:/var/lib/matrix-conduit
BindPaths=/var/lib/private/tuwunel:/var/lib/private/matrix-conduit
ExecStart=/usr/bin/tuwunel
Restart=on-failure
RestartSec=5
TimeoutStopSec=4m
TimeoutStartSec=4m
StartLimitInterval=1m
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
Alias=matrix-tuwunel.service
Generic deployment documentation
Tip
Getting help: If you run into any problems while setting up Tuwunel open an issue on GitHub.
Installing Tuwunel
Static prebuilt binary
You may simply download the binary that fits your machine architecture (x86_64
or aarch64). Run uname -m to see what you need.
Prebuilt fully static binaries can be downloaded from the latest tagged
release here or
main CI branch workflow artifact output. These also include .deb packages
for Debian or Ubuntu and .rpm packages for Red Hat or Fedora.
For the best performance; if using an x86_64 CPU made in the last ~10 years,
we recommend using the -v3- optimised packages. See below for a command to check
what your system supports. If the server refuses to start or exits with an “Illegal
Instruction” error you will need -v2- or -v1- packages instead. The database
backend, RocksDB, benefits from -v2- or greater as it features performance
critical hardware accelerated CRC32 hashing/checksumming.
Linux users can run this script to display which optimization levels they may choose:
cat /proc/cpuinfo | grep -Po '(avx|sse)[235]' | sort -u | sed 's/avx5/v4/;s/avx2/v3/;s/sse3/v2/;s/sse2/v1/' | sort
Compiling
Alternatively, you may compile the binary yourself. We recommend using Nix to build tuwunel as this has the most guaranteed reproducibiltiy and easiest to get a build environment and output going. This also allows easy cross-compilation.
You can run the nix build -L .#static-x86_64-linux-musl-all-features or
nix build -L .#static-aarch64-linux-musl-all-features commands based
on architecture to cross-compile the necessary static binary located at
result/bin/tuwunel. This is reproducible with the static binaries produced
in our CI.
If wanting to build using standard Rust toolchains, make sure you install:
liburing-devon the compiling machine, andliburingon the target host- LLVM and libclang for RocksDB
You can build Tuwunel using cargo build --release --all-features
Adding a Tuwunel user
While Tuwunel can run as any user it is better to use dedicated users for different services. This also allows you to make sure that the file permissions are correctly set up.
In Debian, you can use this command to create a Tuwunel user:
sudo adduser --system tuwunel --group --disabled-login --no-create-home
For distros without adduser (or where it’s a symlink to useradd):
sudo useradd -r --shell /usr/bin/nologin --no-create-home tuwunel
Forwarding ports in the firewall or the router
Matrix’s default federation port is port 8448, and clients must be using port 443.
If you would like to use only port 443, or a different port, you will need to setup
delegation. Tuwunel has config options for doing delegation, or you can configure
your reverse proxy to manually serve the necessary JSON files to do delegation
(see the [global.well_known] config section and the delegation example).
If Tuwunel runs behind a router or in a container and has a different public IP address than the host system these public ports need to be forwarded directly or indirectly to the port mentioned in the config.
Note for NAT users; if you have trouble connecting to your server from the inside of your network, you need to research your router and see if it supports “NAT hairpinning” or “NAT loopback”.
If your router does not support this feature, you need to research doing local
DNS overrides and force your Matrix DNS records to use your local IP internally.
This can be done at the host level using /etc/hosts. If you need this to be
on the network level, consider something like NextDNS or Pi-Hole.
Setting up a systemd service
Two example systemd units for Tuwunel can be found
on the configuration page.
You may need to change the ExecStart= path to where you placed the Tuwunel
binary if it is not /usr/bin/tuwunel.
On systems where rsyslog is used alongside journald (i.e. Red Hat-based distros
and OpenSUSE), put $EscapeControlCharactersOnReceive off inside
/etc/rsyslog.conf to allow color in logs.
If you are using a different database_path other than the systemd unit
configured default /var/lib/tuwunel, you need to add your path to the
systemd unit’s ReadWritePaths=. This can be done by either directly editing
tuwunel.service and reloading systemd, or running systemctl edit tuwunel.service
and entering the following:
[Service]
ReadWritePaths=/path/to/custom/database/path
Creating the Tuwunel configuration file
Now we need to create the Tuwunel’s config file in
/etc/tuwunel/tuwunel.toml. The example config can be found at
tuwunel-example.toml.
Please take a moment to read the config. You need to change at least the server name.
RocksDB is the only supported database backend.
Setting the correct file permissions
If you are using a dedicated user for Tuwunel, you will need to allow it to read the config. To do that you can run this:
sudo chown -R root:root /etc/tuwunel
sudo chmod -R 755 /etc/tuwunel
If you use the default database path you also need to run this:
sudo mkdir -p /var/lib/tuwunel/
sudo chown -R tuwunel:tuwunel /var/lib/tuwunel/
sudo chmod 700 /var/lib/tuwunel/
Setting up the Reverse Proxy
We recommend Caddy as a reverse proxy, as it is trivial to use, handling TLS certificates, reverse proxy headers, etc. transparently with proper defaults. However, Nginx is also well-supported and widely used.
Choose your reverse proxy:
- Caddy Setup Guide - Recommended for ease of use and automatic TLS
- Nginx Setup Guide - Popular choice with extensive documentation
- Traefik Setup Guide - Please help us document this choice
Quick Overview
Regardless of which reverse proxy you choose, you will need to:
-
Reverse proxy the following routes:
/_matrix/- core Matrix C-S and S-S APIs/_tuwunel/- ad-hoc Tuwunel routes such as/local_user_countand/server_version
-
Optionally reverse proxy (recommended):
/.well-known/matrix/clientand/.well-known/matrix/serverif using Tuwunel to perform delegation (see the[global.well_known]config section and the delegation example)/.well-known/matrix/supportif using Tuwunel to send the homeserver admin contact and support page (formerly known as MSC1929)/if you would like to seehewwo from tuwunel woof!at the root
-
Handle ports:
- Port 443 (HTTPS) for client-server API
- Port 8448 for federation (if federating with other homeservers)
Client IP source
Set ip_source when you want Tuwunel to use a spoofing-resistant client IP
source for rate limiting, logging, and security tooling. Leave it unset to keep
the legacy fallback behavior.
Use ip_source = "connect_info" only when Tuwunel accepts direct TCP
connections and should use the TCP peer address. Do not use connect_info for
Unix-socket deployments; leave ip_source unset there.
If Tuwunel is behind a trusted reverse proxy, set ip_source to match the
header that proxy controls. Caddy, Nginx, and Traefik usually use
ip_source = "rightmost_x_forwarded_for". Cloudflare and cloudflared
deployments can use ip_source = "cf_connecting_ip" when Cloudflare supplies
that header.
Only use header-based values when clients cannot connect to Tuwunel directly. If clients can reach Tuwunel without going through the trusted proxy, they can send forged forwarding headers and choose the IP address Tuwunel sees.
See the following spec pages for more details on well-known files:
Examples of delegation:
Other Reverse Proxies
Specific contributions for other proxies are welcome!
Not Recommended:
- Apache: While possible, Apache requires special configuration (
nocanoninProxyPass) to prevent corruption of theX-Matrixheader. - Lighttpd: Its proxy module alters the
X-Matrixauthorization header, breaking federation functionality.
You are done
Now you can start Tuwunel with:
sudo systemctl start tuwunel
Set it to start automatically when your system boots with:
sudo systemctl enable tuwunel
How do I know it works?
You can open a Matrix client, enter your homeserver and try to register.
You can also use these commands as a quick health check (replace
your.server.name).
curl https://your.server.name/_tuwunel/server_version
# If using port 8448
curl https://your.server.name:8448/_tuwunel/server_version
# If federation is enabled
curl https://your.server.name:8448/_matrix/federation/v1/version
- To check if your server can talk with other homeservers, you can use the Matrix Federation Tester. If you can register but cannot join federated rooms check your config again and also check if the port 8448 is open and forwarded correctly.
What’s next?
Audio/Video calls
For Audio/Video call functionality see the TURN Guide.
Appservices
If you want to set up an appservice, take a look at the Appservice Guide.
Reverse Proxy Setup - Caddy
<= Back to Generic Deployment Guide
We recommend Caddy as a reverse proxy, as it is trivial to use, handling TLS certificates, reverse proxy headers, etc. transparently with proper defaults.
Installation
Install Caddy via your preferred method. Refer to the official Caddy installation guide for your distribution.
Configuration
After installing Caddy, create /etc/caddy/conf.d/tuwunel_caddyfile and enter this (substitute
your.server.name with your actual server name):
your.server.name, your.server.name:8448 {
# TCP reverse_proxy
reverse_proxy localhost:8008
# UNIX socket (alternative - comment out the line above and uncomment this)
#reverse_proxy unix//run/tuwunel/tuwunel.sock
}
What this does
- Handles both port 443 (HTTPS) and port 8448 (Matrix federation) automatically
- Automatically provisions and renews TLS certificates via Let’s Encrypt
- Sets all necessary reverse proxy headers correctly
- Routes all traffic to Tuwunel listening on
localhost:8008
Client IP source
By default, Tuwunel treats Caddy as the connecting peer, so registration
logs, rate limiting, and security tooling all attribute requests to Caddy’s
address rather than the real client. If Caddy is the only way clients can
reach Tuwunel, set ip_source = "rightmost_x_forwarded_for" in
tuwunel.toml (or TUWUNEL_IP_SOURCE=rightmost_x_forwarded_for in Docker).
This makes Tuwunel trust the X-Forwarded-For header that Caddy’s
reverse_proxy directive already sets. If you use the Unix-socket
reverse_proxy target, leave ip_source unset instead.
The setting only takes effect at startup, so restart Tuwunel after changing it.
That’s it! Just start and enable the service and you’re set.
sudo systemctl enable --now caddy
Verification
After starting Caddy, verify it’s working by checking:
curl https://your.server.name/_tuwunel/server_version
curl https://your.server.name:8448/_tuwunel/server_version
Caddy and .well-known
Caddy can serve .well-known/matrix/client and .well-known/matrix/server
instead of tuwunel. This is useful when delegating a root domain such as
example.com to a subdomain like matrix.example.com, where Caddy on the root
domain has no Tuwunel upstream of its own.
In this configuration Caddy bypasses Tuwunel’s CORS layer, so the CORS headers recommended by the Matrix specification must be added explicitly.
Note
Caddyfile uses backticks as an alternative string quote so the inline JSON body (which contains double quotes) does not need escaping. Field names and values inside a
headerblock are space-separated; do not place a colon after the field name.
example.com {
@matrix path /.well-known/matrix/*
header @matrix {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
Content-Type "application/json"
}
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://matrix.example.com"}}`
respond /.well-known/matrix/server `{"m.server":"matrix.example.com:443"}`
}
To advertise a MatrixRTC focus (MSC4143) for Element Call, extend the client
response with an org.matrix.msc4143.rtc_foci array pointing at your LiveKit
JWT service:
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}`
=> Continue with “You’re Done”
Reverse Proxy Setup - Nginx
<= Back to Generic Deployment Guide
This guide shows you how to configure Nginx as a reverse proxy for Tuwunel with TLS support.
Installation
Install Nginx via your preferred method. Most distributions include Nginx in their package repositories:
# Debian/Ubuntu
sudo apt install nginx
# Red Hat/Fedora
sudo dnf install nginx
# Arch Linux
sudo pacman -S nginx
Configuration
Create a new configuration file at /etc/nginx/sites-available/tuwunel (or /etc/nginx/conf.d/tuwunel.conf on some distributions):
upstream tuwunel {
server 127.0.0.1:8008; # IP and port where tuwunel is listening
}
# Client-Server API over HTTPS (port 443)
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name matrix.example.com;
# Nginx standard body size is 1MB, which is quite small for media uploads
# Increase this to match the max_request_size in your tuwunel.toml
client_max_body_size 100M;
# Forward requests to Tuwunel
location / {
proxy_pass http://tuwunel;
# Preserve host and scheme - critical for proper Matrix operation
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
# TLS configuration (Let's Encrypt example using certbot)
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
}
# Matrix Federation over HTTPS (port 8448)
# Only needed if you want to federate with other homeservers
# Don't forget to open port 8448 in your firewall!
server {
listen 8448 ssl;
listen [::]:8448 ssl;
http2 on;
server_name matrix.example.com;
# Same body size increase for larger files
client_max_body_size 100M;
# Forward to the same local port as client-server API
location / {
proxy_pass http://tuwunel;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
# TLS configuration (same certificates as above)
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
}
Important Notes
- Replace
matrix.example.comwith your actual server name client_max_body_size: Must match or exceedmax_request_sizein yourtuwunel.tomlip_source: If Nginx is the only way clients can reach Tuwunel, setip_source = "rightmost_x_forwarded_for"so Tuwunel uses the trustedX-Forwarded-Forvalue- Do NOT use
$request_uriinproxy_pass- while some guides suggest this, it’s not necessary for Tuwunel and can cause issues - IPv6: The
listen [::]:443andlisten [::]:8448lines enable IPv6 support. Remove them if you don’t need IPv6
TLS Certificates
The example above uses Let’s Encrypt certificates via certbot. To obtain certificates:
sudo certbot certonly --nginx -d matrix.example.com
Certbot will automatically handle renewal. Make sure to reload Nginx after certificate renewal:
sudo systemctl reload nginx
Optional: Timeout Configuration
The default Nginx timeouts are usually sufficient for Matrix operations. Element’s long-polling /sync requests typically run for 30 seconds, which is within Nginx’s default timeouts.
However, if you experience federation retries or dropped long-poll connections, you can extend the timeouts by adding these lines inside your location / blocks:
location / {
proxy_pass http://tuwunel;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
# Optional: Extend timeouts if experiencing issues
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
Enable the Configuration
If using sites-available/sites-enabled structure:
sudo ln -s /etc/nginx/sites-available/tuwunel /etc/nginx/sites-enabled/
Test the configuration:
sudo nginx -t
If the test passes, reload Nginx:
sudo systemctl reload nginx
Enable Nginx to start on boot:
sudo systemctl enable nginx
Verification
After configuring Nginx, verify it’s working by checking:
curl https://matrix.example.com/_tuwunel/server_version
curl https://matrix.example.com:8448/_tuwunel/server_version
Troubleshooting
Apache Compatibility Note
If you’re considering Apache instead of Nginx: Apache is not well-suited as a reverse proxy for Matrix homeservers. If you must use Apache, you need to use nocanon in your ProxyPass directive to prevent httpd from corrupting the X-Matrix authorization header, which will break federation.
Lighttpd is Not Supported
Lighttpd has known issues with the X-Matrix authorization header, making federation non-functional. We do not recommend using Lighttpd with Tuwunel.
=> Continue with “You’re Done”
Reverse Proxy Setup - Traefik
<= Back to Generic Deployment Guide
Installation
Install Traefik via your preferred method. You can read the official docker quickstart guide or the in-depth walkthrough
Configuration
TLS certificates
You can setup auto renewing certificates with different kinds of acme challenges.
Router configurations
Add tuwunel to your traefik’s network.
services:
tuwunel:
# ...
networks:
- proxy # your traefik network name
networks:
proxy: # your traefik network name
external: true
Be sure to change the your.server.name to your actual tuwunel domain. and the yourcertresolver should be changed to whatever you named it in your traefik config.
You only have to do any one of these methods below.
Labels
To use labels with traefik you need to configure a docker provider.
Then add the labels in your tuwunel’s docker compose file.
services:
tuwunel:
# ...
labels:
- "traefik.enable=true"
- "traefik.http.routers.tuwunel.entrypoints=web"
- "traefik.http.routers.tuwunel.rule=Host(`your.server.name`)"
- "traefik.http.routers.tuwunel.middlewares=https-redirect@file"
- "traefik.http.routers.tuwunel-secure.entrypoints=websecure"
- "traefik.http.routers.tuwunel-secure.rule=Host(`your.server.name`)"
- "traefik.http.routers.tuwunel-secure.tls=true"
- "traefik.http.routers.tuwunel-secure.service=tuwunel"
- "traefik.http.services.tuwunel.loadbalancer.server.port=6167"
- "traefik.http.routers.tuwunel-secure.tls.certresolver=yourcertresolver"
- "traefik.docker.network=proxy"
Config File
To use the config file you need to configure a file provider.
Then add this into your config file.
http:
routers:
tuwunel:
entryPoints:
- "web"
- "websecure"
rule: "Host(`your.server.name`)"
middlewares:
- https-redirect
tls:
certResolver: "yourcertresolver"
service: tuwunel
services:
tuwunel:
loadBalancer:
servers:
# this url should point to your tuwunel installation.
# this should work if your tuwunel container is named tuwunel and is in the same network as traefik.
- url: "http://tuwunel:6167"
passHostHeader: true
Client IP source
If Traefik is the only way clients can reach Tuwunel, set
ip_source = "rightmost_x_forwarded_for" in tuwunel.toml so Tuwunel uses the
trusted X-Forwarded-For value.
Federation
If you will use a .well-known file you can use traefik to redirect .well-known/matrix to tuwunel built-in .well-known file.
replace the rule in either of the methods from
Host(`your.server.name`)
to
Host(`your.tuwunel.domain`) || Host(`your.server.name`) && PathPrefix(`/.well-known/matrix`)
If you are not using a .well-known file you will need to add and expose port 8448 to a traefik entrypoint.
You can then add these to your preferred traefik config method.
you should replace matrixfederationentry with what you named your entrypoint.
Labels:
- "traefik.http.routers.matrix-federation.entrypoints=matrixfederationentry"
- "traefik.http.routers.matrix-federation.rule=Host(`your.server.name`)"
- "traefik.http.routers.matrix-federation.tls=true"
- "traefik.http.routers.matrix-federation.service=matrix-federation"
- "traefik.http.services.matrix-federation.loadbalancer.server.port=6167"
- "traefik.http.routers.matrix-federation.tls.certresolver=yourcertresolver"
Config file:
entryPoints:
- "web"
- "websecure"
- "matrixfederationentry"
Important
Encoded Character Filtering options must be set to
true. This only applies to traefik version 3.6.4 to 3.6.6 and 2.11.32 to 2.11.34
Verification
After starting Traefik, verify it’s working by checking:
curl https://your.server.name/_tuwunel/server_version
curl https://your.server.name:8448/_tuwunel/server_version
=> Continue with “You’re Done”
Example: using the root domain as the homeserver name
<= Back to Generic Deployment Guide
It is possible to host tuwunel on a subdomain such as matrix.example.com but delegate from example.com as the server name. This means that usernames will be @user:example.com rather than @user:matrix.example.com.
Federating servers and clients accessing tuwunel at example.com will attempt to discover the subdomain by accessing the example.com/.well-known/matrix/client and example.com/.well-known/matrix/server endpoints. These need to be set up to point back to matrix.example.com.
Note
In all of the following examples, replace
matrix.example.comwith the subdomain where tuwunel is hosted,<PORT>with the external port for federation, andexample.comwith the domain you want to use as the public-facing homeserver.
Configuration
Make sure the following are set in your configuration file or via environment variables:
- Server name: set
TUWUNEL_SERVER_NAME=example.comor in the configuration file: - Client-server URL: set
TUWUNEL_WELL_KNOWN__CLIENT=https://matrix.example.comor in the configuration file: - Server-server federation domain and port: where
<PORT>is the external port for federation (default 8448, but often 443 when reverse proxying), setTUWUNEL_WELL_KNOWN__SERVER=matrix.example.com:<PORT>or in the configuration file:
Serving .well-known endpoints
With the above configuration, tuwunel will generate and serve the appropriate /.well-known/matrix entries for delegation, so these can be served by reverse proxying /.well-known/matrix on example.com to tuwunel. Alternatively, if example.com is not behind a reverse proxy, static JSON files can be served directly.
Option 1: Static JSON files
At a minimum, the following JSON files should be created:
- At
example.com/.well-known/matrix/client:{ "m.homeserver": { "base_url": "https://matrix.example.com/" } } - At
example.com/.well-known/matrix/server(substituting<PORT>as above):{ "m.server": "matrix.example.com:<PORT>" // e.g. "matrix.example.com:443" }
Option 2: Reverse proxy
These are example configurations if example.com is reverse-proxied behind Nginx or Caddy.
Note
Replace
tuwunelwith the URL where tuwunel is listening; this may look like127.0.0.1:8008,matrix.example.com, ortuwunelif you declared anupstream tuwunelblock.
Important
These configurations need to be applied to the reverse proxy for
example.com, notmatrix.example.com.
Caddy
example.com {
reverse_proxy /.well-known/matrix/* https://matrix.example.com {
header_up Host {upstream_hostport}
}
}
Nginx
Testing
Navigate to example.com/.well-known/matrix/client and example.com/.well-known/matrix/server. These should display results similar to the JSON snippets above.
Entering example.com in the Matrix federation tester should also work.
Additional resources
For a more complete guide, see the Matrix setup with Ansible and Docker documentation on setting up .well-known.
Tuwunel for Arch Linux
Currently Tuwunel is only on the Arch User Repository (AUR).
The Tuwunel AUR packages are community maintained and are not maintained by Tuwunel development team, but the AUR package maintainers are in the Matrix room. Please attempt to verify your AUR package’s PKGBUILD file looks fine before asking for support.
- tuwunel - latest tagged tuwunel
- tuwunel-bin - latest tagged tuwunel static binary
Tuwunel for Debian
Information about downloading and deploying the Debian package. This may also be
referenced for other apt-based distros such as Ubuntu.
Installation
It is recommended to see the generic deployment guide for further information if needed as usage of the Debian package is generally related.
No apt repository is currently offered yet, it is in the works/development.
Configuration
When installed, the example config is placed at /etc/tuwunel/tuwunel.toml
as the default config. The config mentions things required to be changed before
starting.
You can tweak more detailed settings by uncommenting and setting the config
options in /etc/tuwunel/tuwunel.toml.
Running
The package uses the tuwunel.service
systemd unit file to start and stop Tuwunel. The binary is installed at /usr/sbin/tuwunel.
This package assumes by default that Tuwunel will be placed behind a reverse
proxy. The default config options apply (listening on localhost and TCP port
6167). Matrix federation requires a valid domain name and TLS, so you will
need to set up TLS certificates and renewal for it to work properly if you
intend to federate.
Consult various online documentation and guides on setting up a reverse proxy and TLS. Caddy is documented at the generic deployment guide as it’s the easiest and most user friendly.
Tuwunel for FreeBSD
Tuwunel at the moment does not provide FreeBSD builds or have FreeBSD packaging, however Tuwunel does build and work on FreeBSD using the system-provided RocksDB.
Contributions for getting Tuwunel into ports are welcome.
Tuwunel for NixOS
Tuwunel can be acquired by Nix from various places:
- The
flake.nixat the root of the repo - The
default.nixat the root of the repo - From Tuwunel’s binary cache
A community maintained NixOS package is available at tuwunel
NixOS module
A NixOS module ships with Nixpkgs as services.matrix-tuwunel,
available in 25.11 and unstable. It generates tuwunel.toml from a settings attrset
and runs the server under a hardened systemd unit (DynamicUser, ProtectSystem=strict,
strict SystemCallFilter).
Minimal configuration:
{
services.matrix-tuwunel = {
enable = true;
settings.global = {
server_name = "example.com";
address = [ "127.0.0.1" "::1" ];
port = [ 6167 ];
allow_federation = true;
};
};
}
Notable defaults:
- User and group
tuwunel(override viaservices.matrix-tuwunel.user/.group). - Database under
/var/lib/tuwunel/(override viaservices.matrix-tuwunel.stateDirectory). - Listens on
127.0.0.1and::1port6167.
Anything placed under settings.global is written verbatim into the [global] table of
tuwunel.toml, so the configuration reference applies directly.
UNIX sockets
The module exposes unix_socket_path and unix_socket_perms directly:
services.matrix-tuwunel.settings.global = {
unix_socket_path = "/run/tuwunel/tuwunel.sock";
unix_socket_perms = 660;
};
Leave address unset (or null) when using a socket. The systemd unit already permits
AF_UNIX, so no further overrides are needed.
Migrating from services.matrix-conduit
services.matrix-tuwunel replaces the legacy services.matrix-conduit
module that older guides reference. Most settings carry over because both render the
same TOML schema. When migrating:
- Disable
services.matrix-conduitand enableservices.matrix-tuwunel. - Confirm the database is RocksDB. Tuwunel dropped SQLite in favor of RocksDB; if you ran a SQLite Conduit, migrate first with conduit_toolbox.
- Either set
services.matrix-tuwunel.stateDirectoryto match your existingdatabase_path, or move the database under/var/lib/tuwunel/.
Tuwunel for Red Hat
Information about downloading and deploying the Red Hat package. This may also be
referenced for other rpm-based distros such as CentOS.
Installation
It is recommended to see the generic deployment guide for further information if needed as usage of the RPM package is generally related.
No rpm repository is currently offered yet, it is in the works/development.
Configuration
When installed, the example config is placed at /etc/tuwunel/tuwunel.toml
as the default config. The config mentions things required to be changed before
starting.
You can tweak more detailed settings by uncommenting and setting the config
options in /etc/tuwunel/tuwunel.toml.
Running
The package uses the tuwunel.service
systemd unit file to start and stop Tuwunel. The binary is installed at /usr/sbin/tuwunel.
This package assumes by default that Tuwunel will be placed behind a reverse
proxy. The default config options apply (listening on localhost and TCP port
8008). Matrix federation requires a valid domain name and TLS, so you will
need to set up TLS certificates and renewal for it to work properly if you
intend to federate.
Consult various online documentation and guides on setting up a reverse proxy and TLS. Caddy is documented at the generic deployment guide as it’s the easiest and most user friendly.
Podman / Quadlets
Podman and Quadlets are well supported on Redhat-based distributions. See Podman and systemd for examples.
Containers
Tuwunel ships as a small, statically linked OCI image that runs unprivileged on any container runtime. Pick a deployment style based on how you already manage services on the host.
-
Docker. Pull prebuilt images from GHCR or Docker Hub, then run standalone or via one of the provided
docker-composestacks (with Caddy, Traefik, or a bring-your-own reverse proxy). -
Kubernetes. Community-maintained Helm chart for cluster deployments. Tuwunel itself does not scale horizontally, so the chart runs a single replica with persistent storage.
-
Podman with Quadlets. Rootless deployment managed by systemd user units, suited to single-host setups where containers should behave like native services.
Image registries
| Registry | Image | Tags |
|---|---|---|
| GitHub Registry | ghcr.io/matrix-construct/tuwunel | latest, preview, main |
| Docker Hub | docker.io/jevolk/tuwunel | latest, preview, main |
Three rolling tags trade update frequency for confidence.
| Tag | Source | Cadence | Use when |
|---|---|---|---|
:latest | Most recent tagged release | ~monthly | Production. Default choice. |
:preview | Selected higher-confidence updates | ~weekly | You want fixes between releases without chasing main. |
:main | Every reviewed merge to the main branch | ~daily | You track development and accept unknown-risk changes. |
For automated updates we strongly advise tracking :latest.
Tuwunel for Docker
Docker
To run tuwunel with Docker you can either build the image yourself or pull it from a registry.
Use a registry
OCI images for tuwunel are available in the registries listed below.
| Registry | Image | Size | Notes |
|---|---|---|---|
| GitHub Registry | ghcr.io/matrix-construct/tuwunel:latest | Most recent tagged release. Recommended for automated updates (~monthly). | |
| Docker Hub | docker.io/jevolk/tuwunel:latest | Most recent tagged release. Recommended for automated updates (~monthly). | |
| GitHub Registry | ghcr.io/matrix-construct/tuwunel:preview | Selected higher-confidence updates between releases (~weekly). | |
| Docker Hub | docker.io/jevolk/tuwunel:preview | Selected higher-confidence updates between releases (~weekly). | |
| GitHub Registry | ghcr.io/matrix-construct/tuwunel:main | Every reviewed merge to the main branch (~daily). | |
| Docker Hub | docker.io/jevolk/tuwunel:main | Every reviewed merge to the main branch (~daily). |
Run
When you have the image you can simply run it with
docker run -d -p 8448:8008 \
-v db:/var/lib/tuwunel/ \
-e TUWUNEL_SERVER_NAME="your.server.name" \
-e TUWUNEL_ALLOW_REGISTRATION=false \
--name tuwunel $LINK
or you can use docker compose.
The -d flag lets the container run in detached mode. You may supply an
optional tuwunel.toml config file, the example config can be found
here. You can pass in different env vars to
change config values on the fly. You can even configure tuwunel completely by
using env vars. For an overview of possible values, please take a look at the
docker-compose.yml file.
If you just want to test tuwunel for a short time, you can use the --rm
flag, which will clean up everything related to your container after you stop
it.
Docker-compose
If the docker run command is not for you or your setup, you can also use one
of the provided docker-compose files.
Depending on your proxy setup, you can use one of the following files:
- If you already have a
traefikinstance set up, usedocker-compose.for-traefik.yml - If you don’t have a
traefikinstance set up and would like to use it, usedocker-compose.with-traefik.yml - If you want a setup that works out of the box with
caddy-docker-proxy, usedocker-compose.with-caddy.ymland replace allexample.complaceholders with your own domain - For any other reverse proxy, use
docker-compose.yml
When picking the traefik-related compose file, rename it so it matches
docker-compose.yml, and rename the override file to
docker-compose.override.yml. Edit the latter with the values you want for your
server.
When picking the caddy-docker-proxy compose file, it’s important to first
create the caddy network before spinning up the containers:
docker network create caddy
After that, you can rename it so it matches docker-compose.yml and spin up the
containers!
Additional info about deploying tuwunel can be found here.
Run
If you already have built the image or want to use one from the registries, you can just start the container and everything else in the compose file in detached mode with:
docker compose up -d
Note: Don’t forget to modify and adjust the compose file to your needs.
Nix build
Tuwunel’s Nix images are built using buildLayeredImage.
This ensures all OCI images are repeatable and reproducible by anyone, keeps the
images lightweight, and can be built offline.
This also ensures portability of our images because buildLayeredImage builds
OCI images, not Docker images, and works with other container software.
The OCI images are OS-less with only a very minimal environment of the tini
init system, CA certificates, and the tuwunel binary. This does mean there is
not a shell, but in theory you can get a shell by adding the necessary layers
to the layered image. However it’s very unlikely you will need a shell for any
real troubleshooting.
The flake file for the OCI image definition is at nix/pkgs/oci-image/default.nix.
To build an OCI image using Nix, the following outputs can be built:
nix build -L .#oci-image(default features, x86_64 glibc)nix build -L .#oci-image-x86_64-linux-musl(default features, x86_64 musl)nix build -L .#oci-image-aarch64-linux-musl(default features, aarch64 musl)nix build -L .#oci-image-x86_64-linux-musl-all-features(all features, x86_64 musl)nix build -L .#oci-image-aarch64-linux-musl-all-features(all features, aarch64 musl)
Use Traefik as Proxy
As a container user, you probably know about Traefik. It is a easy to use
reverse proxy for making containerized app and services available through the
web. With the two provided files,
docker-compose.for-traefik.yml (or
docker-compose.with-traefik.yml) and
docker-compose.override.yml, it is equally easy
to deploy and use tuwunel, with a little caveat. If you already took a look at
the files, then you should have seen the well-known service, and that is the
little caveat. Traefik is simply a proxy and loadbalancer and is not able to
serve any kind of content, but for tuwunel to federate, we need to either
expose ports 443 and 8448 or serve two endpoints .well-known/matrix/client
and .well-known/matrix/server.
With the service well-known we use a single nginx container that will serve
those two files.
Voice communication
See the TURN page.
Podman, Quadlets, and systemd
For a rootless setup, we can use quadlets and systemd to manage the container lifecycle.
Important
If this is the first container managed with quadlets for your user, ensure that linger is enabled so your containers are not killed after logging out.
sudo loginctl enable-linger <username>
Step One
Copy quadlet files to ~/.config/containers/systemd/tuwunel
tuwunel.container
tuwunel container quadlet
# tuwenel.container
[Unit]
Description=Tuwunel Matrix Homeserver
[Container]
ContainerName=tuwunel-homeserver
Image=ghcr.io/matrix-construct/tuwunel:latest
PublishPort=8008:8008
Volume=tuwunel-db:/var/lib/tuwunel/
#Example location in ~/tuwunel/config/
Volume=%h/tuwunel/config/tuwunel.toml:/etc/tuwunel.toml
EnvironmentFile=tuwunel.env
[Service]
# Uncomment when your system is properly configured, restart=always can mask start up errors.
#Restart=always
[Install]
WantedBy=default.target
tuwunel-db.volume
tuwunel database volume quadlet
[Volume]
VolumeName=tuwunel-db
tuwunel.env
tuwunel environment variable quadlet
TUWUNEL_SERVER_NAME="your.server.tld"
TUWUNEL_PORT=8008
TUWUNEL_MAX_REQUEST_SIZE=20000000
TUWUNEL_ALLOW_REGISTRATION=true
TUWUNEL_REGISTRATION_TOKEN=<replace with a passphrase or random string>
TUWUNEL_ALLOW_FEDERATION=true
TUWUNEL_TRUSTED_SERVERS=["matrix.org"]
TUWUNEL_LOG=info
#Listen on this host for IPv4 and v6
TUWUNEL_ADDRESS=["0.0.0.0", "::"]
#Tell Tuwunel to use the user config file
TUWUNEL_CONFIG=/etc/tuwunel.toml
mkdir -p ~/.config/containers/systemd/tuwunel
Step Two
Modify tuwunel.env and tuwunel.toml
to desired values. This can be saved in your user home directory if desired.
Step Three
- Reload daemon to generate our systemd unit files:
systemctl --user daemon-reload
Step Four
- Start tuwunel:
systemctl --user start tuwunel
Logging
To check the logs, run:
systemctl --user status tuwunel
or
podman logs tuwunel-homeserver
Troubleshooting systemd unit file generation
Look for errors in the output:
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun
Tuwunel for Kubernetes
Tuwunel doesn’t support horizontal scalability or distributed loading natively, however a community maintained Helm Chart is available here to run Tuwunel on Kubernetes: https://github.com/AreYouLoco/tuwunel-helm and the legacy conduwuit version: https://gitlab.cronce.io/charts/conduwuit.
Should changes need to be made, please reach out to the maintainer in our Matrix room as this is not maintained/controlled by the Tuwunel maintainers.
Authentication systems
Tuwunel gives you fine-grained control over who can register and how users authenticate. This chapter covers everything from basic password login and token-based invitations to full OpenID Connect federation.
-
Legacy Authentication — Control who can register, token-based invitations, guest access, and basic login options.
-
Identity Providers — Single-sign-on login via GitHub, Google, Keycloak, and other OAuth/OIDC providers.
-
OIDC Services — Tuwunel’s built-in OIDC authorization server for next-generation Matrix applications.
-
LDAP Delegation — Delegate user management and password authentication to an LDAP directory.
-
Enterprise JWT — Operator-controlled signing key can mint a token that authenticates as any user.
Legacy Authentication
By default, registration is disabled. You must explicitly enable it and choose what conditions, if any, a prospective user must meet before an account is created.
Enabling registration
Set allow_registration = true to enable registration. On its own this is not
enough — you must also configure at least one of the following:
- A registration token (recommended)
- The open-registration confirmation flag (not recommended)
- One or more identity providers
Token-based registration
A registration token acts as a shared secret that prospective users must supply when creating an account. This is the recommended approach for private or invite-only servers.
Static token — set a single token directly in the config:
allow_registration = true
registration_token = "o&^uCtes4HPf0Vu@F20jQeeWE7"
File-based tokens — read tokens from a file, one per line or separated by whitespace. Useful for rotating tokens without restarting the server:
allow_registration = true
registration_token_file = "/etc/tuwunel/.reg_tokens"
Both options can be set at the same time; the file takes priority.
Admin-issued tokens — generate short-lived or single-use tokens from the admin room without touching the config file:
| Command | Description |
|---|---|
!admin token issue | Issue a token with no restrictions. |
!admin token issue --once | Issue a single-use token (shorthand for --max-uses 1). |
!admin token issue --max-uses <N> | Issue a token that expires after N uses. |
!admin token issue --max-age <duration> | Issue a token that expires after a duration (e.g. 30m, 7d). |
!admin token revoke <token> | Revoke a token immediately. |
!admin token list | List all active tokens. |
Open registration
To allow anyone to register without a token, you must set an additional confirmation flag that acknowledges the abuse risk:
allow_registration = true
yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true
This is not recommended for public-facing servers. Consider token-based registration or SSO providers instead.
Guest registration
Guest accounts are anonymous sessions that some clients (e.g. Element) create automatically before a user logs in. Guest registration is separate from normal registration and is disabled by default.
| Option | Default | Description |
|---|---|---|
allow_guest_registration | false | Allow guest account creation. |
log_guest_registrations | false | Log each guest registration to the admin room. May be noisy on public servers. |
allow_guests_auto_join_rooms | false | Allow guest users to auto-join rooms listed in auto_join_rooms. |
Login options
These options control which login methods are accepted regardless of how accounts were created.
| Option | Default | Description |
|---|---|---|
login_with_password | true | Accept username and password login. Set to false to enforce SSO-only login. |
login_via_token | true | Accept m.login.token login tokens. Disabling this can break SSO flows where the server issues a token to complete the login. |
login_via_existing_session | true | Allow an authenticated session to mint a login token that a second client can use to log in. Requires interactive re-authentication. Disable if you want to prevent clients from spawning additional sessions this way. |
Token and session lifetimes
| Option | Default | Description |
|---|---|---|
login_token_ttl | 120000 | Lifetime of m.login.token tokens in milliseconds (default: 2 minutes). |
access_token_ttl | 604800 | Lifetime of access tokens in seconds for clients that support refresh tokens. After expiry the client is soft-logged-out until it refreshes (default: 7 days). |
openid_token_ttl | 3600 | Lifetime of OpenID 1.0 tokens in seconds. These are used for Matrix account integrations such as Vector Integrations in Element, not for OIDC/OpenID Connect logins (default: 1 hour). |
Emergency password
The emergency password lets you log in to the server bot account
(@conduit:<server_name>) when normal access is unavailable — for example,
if you have lost access to your admin room.
emergency_password = "F670$2CP@Hw8mG7RY1$%!#Ic7YA"
Remove this option and restart the server once you have regained access — all sessions for the bot account are logged out when it is unset. See the troubleshooting guide for other recovery methods.
OIDC Server (Next-Gen Auth)
Tuwunel includes a built-in OIDC authorization server that implements
next-generation Matrix authentication. Matrix clients that support next-gen
auth interact with this server directly instead of using the legacy
m.login.password or m.login.sso flows.
This implements the following MSCs:
- MSC2964 — OAuth 2.0 authorization code grant for Matrix
- MSC2965 — OIDC provider discovery and account management
- MSC2966 — Dynamic client registration (RFC 7591)
- MSC2967 — OAuth 2.0 API scopes for Matrix
- MSC3824 — OIDC-aware client hint (
oidc_aware_preferred) - MSC4312 — Cross-signing reset requiring SSO re-authentication for OIDC devices
Prerequisites
The OIDC server activates automatically when both of the following are configured:
- At least one
[[global.identity_provider]]block (see Identity Providers) well_known.clientin[global.well_known]:
[global.well_known]
client = "https://matrix.example.com"
The well_known.client URL becomes the OIDC issuer URL. If only one of the
two prerequisites is met, Tuwunel logs a warning at startup and the OIDC
server does not start.
Endpoints
Discovery
| Endpoint | Description |
|---|---|
/.well-known/openid-configuration | OIDC discovery document (RFC 8414) |
/_matrix/client/v1/auth_issuer | Matrix auth issuer discovery (MSC2965) |
/_matrix/client/v1/auth_metadata | Authorization server metadata |
/_matrix/client/unstable/org.matrix.msc2965/auth_issuer | Unstable auth issuer endpoint |
/_matrix/client/unstable/org.matrix.msc2965/auth_metadata | Unstable metadata endpoint |
Authorization server
| Method | Endpoint | Description |
|---|---|---|
GET | /_tuwunel/oidc/authorize | Authorization endpoint — starts the OAuth flow |
GET | /_tuwunel/oidc/_complete | Completes authorization after provider callback |
POST | /_tuwunel/oidc/token | Token endpoint — exchanges auth codes and refresh tokens |
POST | /_tuwunel/oidc/revoke | Token revocation (RFC 7009) |
GET | /_tuwunel/oidc/jwks | JSON Web Key Set — public keys for JWT verification |
GET/POST | /_tuwunel/oidc/userinfo | Userinfo endpoint — returns claims for a bearer token |
POST | /_tuwunel/oidc/registration | Dynamic client registration (RFC 7591) |
Account management UI
| Endpoint | Description |
|---|---|
GET /_tuwunel/oidc/account | Account management page (MSC4191) |
Dynamic Client Registration
Matrix clients that support next-gen auth register themselves with Tuwunel before initiating the authorization flow, using RFC 7591 dynamic client registration:
POST /_tuwunel/oidc/registration
No pre-configuration of clients is required — any Matrix client that supports dynamic registration can authenticate against Tuwunel’s OIDC server.
Account Management UI
Tuwunel serves a built-in account management page at /_tuwunel/oidc/account
for users authenticated via OIDC. From this page users can:
- View all active OIDC sessions
- See which client and identity provider each session belongs to
- End individual sessions
- Edit their profile
The URL for this page is advertised in the authorization server metadata under
account_management_uri (MSC4191).
Cross-Signing Protection (MSC4312)
Devices that authenticated via the OIDC server are tracked as “OIDC devices.” When such a device attempts to reset cross-signing keys, Tuwunel requires re-authentication via the original identity provider through the SSO UIAA flow. This prevents a compromised client from resetting cross-signing without the user actively re-authorizing through their identity provider.
Administrators can inspect which devices are OIDC devices using the admin query commands for OAuth sessions.
Signing Keys
Tuwunel generates and persists an ECDSA signing key on first startup, stored
in the oidc_signingkey database table. The corresponding public key is
served at /_tuwunel/oidc/jwks. This key signs ID tokens (JWTs) issued by
the token endpoint.
Startup Warnings
If an [[global.identity_provider]] is configured but well_known.client is
missing, Tuwunel logs:
OIDC server (Next-gen auth) requires `well_known.client` to be configured to serve your `identity_provider`.
The OIDC server will not start. Traditional SSO (legacy m.login.sso flow)
continues to work without the OIDC server.
LDAP Authentication
Tuwunel can authenticate password logins against an LDAP directory. When a
user logs in with m.login.password, Tuwunel locates them in the directory,
verifies the password by binding as that user, and creates a Matrix account on
first successful login if one does not yet exist.
LDAP support is a compile-time feature. It is not part of the default
build — you must compile with --features ldap (or use a release artifact
that already includes it) before any of the configuration below has effect.
Enabling LDAP
LDAP is configured under a single [global.ldap] section. The minimum to
enable it is the enable flag and a uri:
[global.ldap]
enable = true
uri = "ldaps://ldap.example.org:636"
base_dn = "ou=users,dc=example,dc=org"
The URI scheme decides the transport: ldap:// is plaintext, ldaps://
upgrades to TLS using the system’s installed CAs.
When enable = true, every m.login.password request is routed through LDAP
first. Local password authentication still happens as a fallback when the
LDAP search returns zero matches — useful for the bootstrap admin account or
for service users that should not exist in the directory. If the LDAP search
returns two or more matches the login is rejected.
Bind modes
Tuwunel supports three bind strategies, selected by what you put in bind_dn
and bind_password_file.
Search-then-bind (recommended)
A service account binds to the directory, searches for the user, then Tuwunel re-binds as the user with the supplied password to verify it. This mode is required if you want admin synchronization (see below).
[global.ldap]
enable = true
uri = "ldaps://ldap.example.org:636"
base_dn = "ou=users,dc=example,dc=org"
bind_dn = "cn=ldap-reader,dc=example,dc=org"
bind_password_file = "/etc/tuwunel/.ldap_bind_password"
filter = "(&(objectClass=person)(memberOf=cn=matrix,ou=groups,dc=example,dc=org))"
The bind password is read from bind_password_file rather than placed inline
in the config. The file must be readable by the Tuwunel process and must not
be empty.
Anonymous search-then-bind
If your directory permits anonymous searches, omit both bind_dn and
bind_password_file. Tuwunel skips the initial bind and queries the
directory unauthenticated, then re-binds as the user to verify the password.
Direct bind
If bind_dn contains the literal substring {username}, Tuwunel skips the
search entirely and binds directly with that DN, substituting the user’s
localpart for {username} and using the supplied login password as the bind
password:
[global.ldap]
enable = true
uri = "ldaps://ldap.example.org:636"
bind_dn = "cn={username},ou=users,dc=example,dc=org"
This is the simplest mode but has two limitations: it cannot apply a search filter (so anyone in the bind DN’s subtree can log in), and admin synchronization does not work because Tuwunel never gets a chance to query the directory under a service account.
Configuration reference
| Field | Default | Description |
|---|---|---|
enable | false | Master switch for LDAP login. Has no effect unless the binary was compiled with --features ldap. |
uri | — | LDAP server URI. ldap://host:389 for plaintext, ldaps://host:636 for TLS. |
base_dn | "" | Subtree under which user searches are rooted. |
bind_dn | — | DN used for the initial bind. Contains {username} for direct-bind mode; otherwise identifies a service account. Omit for anonymous search. |
bind_password_file | — | Path to a file containing the password for bind_dn. Ignored in direct-bind mode (the user’s login password is used). |
filter | "(objectClass=*)" | LDAP search filter applied during user lookup. Supports {username} substitution. |
uid_attribute | "uid" | Attribute that uniquely identifies the user. Returned entries must contain the user’s localpart in this attribute (or in name_attribute). |
name_attribute | "givenName" | Secondary attribute checked for the localpart. Useful when login should match either an account name or a display name. |
admin_base_dn | "" | Subtree for the admin search. Falls back to base_dn when empty. |
admin_filter | "" | Filter that selects administrative users. Empty disables admin synchronization entirely. Supports {username} substitution. |
The localpart match is case-insensitive — Tuwunel sends a lowercased version
of the localpart through {username} substitution and accepts an entry if
either the original or lowercased form appears in uid_attribute or
name_attribute.
Admin synchronization
Setting admin_filter to a non-empty value turns the LDAP directory into the
source of truth for who is a Tuwunel admin. On every successful LDAP login,
Tuwunel runs a second search rooted at admin_base_dn (or base_dn if
empty) using admin_filter. Membership in the result set is compared against
the user’s current admin status in Tuwunel:
- In LDAP admin set, not a Tuwunel admin → granted admin.
- Not in LDAP admin set, currently a Tuwunel admin → admin revoked.
- Otherwise → no change.
Two examples:
# Admins are users with a custom objectClass.
admin_filter = "(objectClass=tuwunelAdmin)"
# Admins are members of an LDAP group, looked up under a different subtree.
admin_base_dn = "ou=admins,dc=example,dc=org"
admin_filter = "(uid={username})"
Admin synchronization only runs in the search-then-bind modes. In direct-bind
mode the admin search is silently skipped — manage admins manually with
!admin users make-admin and !admin users revoke-admin if you need that
combination.
Account lifecycle
The first time a user successfully authenticates against LDAP, Tuwunel
auto-creates a local Matrix account for them (the same way Synapse,
Nextcloud, and Jellyfin behave). The account is registered with origin
"ldap" and a placeholder password value — the local password field is
never consulted for an LDAP user, so they can only log in by re-authenticating
against the directory.
Subsequent logins reuse the existing account and only update admin status
if admin_filter is configured.
Deactivating a user in LDAP prevents future logins but does not
automatically deactivate or delete the corresponding Matrix account. Use
!admin users deactivate if you also want to remove access to existing
sessions and devices.
Admin commands for testing
Two admin commands invoke the LDAP code paths directly without going through
the login API. They are useful for verifying that filter, uid_attribute,
bind_dn, and TLS configuration produce the expected results.
| Command | Description |
|---|---|
!admin query users search-ldap @alice:example.org | Run the configured search for a user and print the matching DNs along with their admin status. Returns an empty list if the filter matches nothing. |
!admin query users auth-ldap "cn=alice,ou=users,dc=example,dc=org" "<password>" | Attempt a direct bind with the given DN and password. Use this to confirm credentials and TLS setup; the password is logged in plaintext to the admin room, so revoke or rotate afterwards. |
Both commands are gated by the ldap build feature.
Disabling password login for non-LDAP users
Tuwunel’s LDAP integration always falls back to local password verification
when the LDAP search returns no matches. To enforce LDAP-only login for
everyone (apart from accounts that authenticate via SSO), pair LDAP with a
restrictive filter that matches every legitimate user, and remove or
invalidate local passwords for accounts that should no longer be able to log
in directly. Alternatively, set login_with_password = false and rely on
identity providers for non-LDAP users.
JSON web token for enterprise
Tuwunel can accept signed JSON Web Tokens as proof of identity, both as a
login flow (POST /_matrix/client/v3/login with type = org.matrix.login.jwt)
and as a User-Interactive Authentication step for sensitive operations.
This is an enterprise feature intended for managed deployments where identity is owned by an external system. Two typical uses:
-
Externalized user management. A hosting provider or corporate identity service mints short-lived JWTs for its users; Tuwunel becomes a stateless consumer of those tokens and creates Matrix accounts on first login.
-
Account override. An operator-controlled signing key can mint a token that authenticates as any user for password resets, key recovery, or legal compliance without modifying the user’s credentials.
Enabling JWT
The minimum configuration to accept JWTs is enable = true and a key:
[global.jwt]
enable = true
key = "yJKn0!E2g$5Hs!rUv9NQwL@ZmpQ3xVT"
With these defaults, Tuwunel will accept any HS256-signed token whose
sub claim is the localpart of an MXID on this server.
POST /_matrix/client/v3/login then accepts:
{
"type": "org.matrix.login.jwt",
"token": "<jwt>"
}
GET /_matrix/client/v3/login advertises the flow as long as
enable = true.
The sub (subject) claim is the localpart of the user’s MXID. Tuwunel
combines it with this server’s server_name to form the full MXID. The
subject is lowercased before lookup.
For a server with server_name = "matrix.example.org", a token with
"sub": "alice" authenticates as @alice:matrix.example.org. The token
issuer must agree with the server on this naming.
Configuration reference
| Field | Default | Description |
|---|---|---|
enable | false | Master switch for JWT login. Also gates the UIAA flow. |
key | — | Verification key. Plaintext, base64, or PEM depending on format. Sensitive — keep private when used as an HMAC secret. |
format | "HMAC" | One of HMAC, B64HMAC, ECDSA, EDDSA. Selects how key is decoded. |
algorithm | "HS256" | JWT alg header value. Must be compatible with format. |
register_user | true | Auto-create a Matrix account on first valid login if the user doesn’t already exist. Set to false to require pre-existing accounts. |
audience | [] | Optional list of accepted aud claim values. When non-empty, tokens must claim at least one entry; aud becomes a required claim. |
issuer | [] | Optional list of accepted iss claim values. When non-empty, tokens must claim at least one entry; iss becomes a required claim. |
require_exp | false | If true, tokens without an exp claim are rejected. Defaults to false for Synapse compatibility. |
require_nbf | false | If true, tokens without an nbf claim are rejected. |
validate_exp | true | When exp is present, enforce that the token has not expired. |
validate_nbf | true | When nbf is present, enforce that the token has reached its validity window. |
key is also accepted under the alias secret to match Synapse config
files.
Migrating from Synapse
Synapse’s JWT support uses a configuration of similar shape. To migrate
a Synapse experimental_features.jwt_config block:
| Synapse | Tuwunel |
|---|---|
enabled | enable |
secret | key (also accepted as secret for direct migration) |
algorithm | algorithm |
audiences | audience |
issuer | issuer (now a list; wrap a single value as ["..."]) |
Synapse defaults to optional exp/nbf and accepts the localpart in
the sub claim. Tuwunel matches both behaviors out of the box, so a
straight key+algorithm port should authenticate the same set of tokens.
Account lifecycle
The first time a token authenticates as a user that does not yet exist:
- If
register_user = true, Tuwunel creates the account with origin"jwt"and a placeholder password marker. The local password field is never read for a JWT-authenticated user — they can only re-authenticate by presenting another valid JWT. - If
register_user = false, the request fails withM_NOT_FOUNDand the account is not created.
Subsequent logins reuse the existing account.
JWT does not synchronize admin status, group membership, or display names — the token grants login only. If you need ongoing identity attribute synchronization, use LDAP or an OIDC identity provider instead.
UIAA — JWT for account override
When enable = true, the m.login.jwt UIAA stage becomes available
alongside m.login.password and m.login.sso for sensitive operations
that require interactive re-authentication (deactivate account, change
password, add 3PID, etc.).
A JWT presented at the UIAA stage validates the user but does not auto-register: the token’s subject must already exist as a Matrix account. This restriction prevents an account-override flow from accidentally creating new accounts when an operator intends only to substitute identity for an existing one.
A typical operator workflow for a forced password reset:
-
Sign a JWT with
subset to the target user’s localpart. -
Submit it as the
authfield ofPOST /_matrix/client/v3/account/password:{ "auth": { "type": "org.matrix.login.jwt", "token": "<jwt>" }, "new_password": "<new password>" } -
Tuwunel validates the signature, confirms the user exists, and completes the password change without ever consulting the user’s existing credentials.
Limit access to the signing key accordingly. Anyone with the HMAC secret, or the matching ECDSA/EdDSA private key, can authenticate as any user on the server.
Key formats and algorithms
format selects how key is interpreted. algorithm selects the JWT
signing algorithm. The two must agree.
| Format | Algorithm | Key content |
|---|---|---|
HMAC (default) | HS256, HS384, HS512 | Plaintext shared secret. |
B64HMAC | HS256, HS384, HS512 | Base64-encoded shared secret. Use this when the secret contains non-printable bytes. |
ECDSA | ES256, ES384 | PEM-encoded ECDSA public key. |
EDDSA | EdDSA | PEM-encoded Ed25519 public key. |
For asymmetric formats (ECDSA, EDDSA) the key is the public key —
Tuwunel only verifies, it never signs. The corresponding private key
stays with the issuer.
# HMAC shared secret (Synapse-compatible default)
format = "HMAC"
algorithm = "HS256"
key = "..."
# ECDSA public-key
format = "ECDSA"
algorithm = "ES256"
key = """-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----"""
Time-bounded tokens
exp (expiration) and nbf (not-before) follow the spec semantics:
seconds since the Unix epoch. Configure based on the issuer’s behavior:
- Issuer always sets
exp— setrequire_exp = trueto reject any token without an expiration.validate_expistrueby default. - Issuer never sets
exp— leave bothrequire_expandvalidate_expat their defaults; tokens withoutexpare accepted as non-expiring (use cautiously). - Mixed — leave
require_exp = falseandvalidate_exp = true(the defaults). This is Synapse-compatible:expis optional but enforced when present.
nbf is symmetric with exp and most deployments leave it unset on both
issuer and consumer.
Audience and issuer validation
By default no aud or iss validation is performed. To restrict
acceptance to tokens issued by, or destined for, specific systems, set
the respective config field:
audience = ["https://matrix.example.org"]
issuer = ["https://idp.example.org"]
When set, the corresponding claim becomes required in addition to being checked against the allowed list. Multiple values are treated as “any of these is acceptable.”
Identity Providers
Tuwunel can delegate login to external OAuth/OIDC identity providers. Each configured provider appears as an option on the client’s login page. Users are redirected to the provider to authenticate, then returned to Tuwunel which maps their identity to a Matrix account.
Provider guides
Configuring Tuwunel
Each provider is a [[global.identity_provider]] table in your configuration
file. Multiple providers can be configured by repeating the table header. Container users
please refer to the section on environment variables instead.
Required fields
| Field | Description |
|---|---|
brand | Brand name of the provider: Apple, Facebook, GitHub, GitLab, Google, Keycloak, MAS, Twitter, or any custom string. Case-insensitive. Known brands get built-in defaults and workarounds. |
client_id | The OAuth application ID issued by the provider. This becomes the provider’s unique ID within Tuwunel and must never change — Tuwunel associates stored identities to it. |
Authentication
| Field | Default | Description |
|---|---|---|
client_secret | — | OAuth client secret issued by the provider. |
client_secret_file | — | Path to a file containing the client secret. Takes priority over client_secret. Example: /etc/tuwunel/.github_secret |
Discovery
| Field | Default | Description |
|---|---|---|
issuer_url | brand default | Provider’s OIDC issuer URL. Pre-supplied for well-known public providers. Required for self-hosted providers. Must match exactly what the provider expects and must never change. |
base_path | brand default | Extra path after issuer_url leading to the .well-known directory. GitHub uses login/oauth/, for example. Pre-populated for known brands. |
discovery_url | — | Fully overrides the .well-known/openid-configuration location. For developers or non-standard providers. |
discovery | true | Whether to perform OIDC discovery at all. |
Callback
| Field | Description |
|---|---|
callback_url | The callback URL registered with the provider when you created the OAuth application. Must be exactly: https://<your-matrix-server>/_matrix/client/unstable/login/sso/callback/<client_id> |
Login behavior
| Field | Default | Description |
|---|---|---|
default | false | Mark this provider as the default for /_matrix/client/v3/login/sso/redirect (the endpoint without a provider ID). Required when multiple providers are configured and some clients (e.g. FluffyChat) need a single redirect target. If exactly one provider is configured it is implicitly the default. (Experimental) Multiple providers can share default = true — all must authorize successfully in sequence. |
name | brand | Display name shown on the login page. Useful when multiple providers share the same brand. |
icon | brand default | MXC URI for the provider’s icon. Known brands have built-in icons. |
scope | all | List of OAuth scopes to request. Empty array means all scopes configured in the provider application. Users can further restrict scopes during authorization. |
User ID mapping
| Field | Default | Description |
|---|---|---|
userid_claims | all | Claims used to compute the Matrix localpart for new registrations. When empty, Tuwunel avoids generated IDs where possible. The special value "unique" forces generated IDs exclusively. The claim "sub" takes precedence over all others when listed. |
trusted | false | Inverts user matching: instead of registering a new account when claims conflict with existing users, Tuwunel finds the first matching user and grants access to it. Only set this for providers you self-host and fully control. Never use with public providers (GitHub, GitLab, Google, etc.) — it enables account takeover. |
unique_id_fallbacks | true | When no claim maps cleanly to an available username, generate a unique random localpart as a fallback. Set to false on private servers where random usernames are undesirable — a misconfiguration will produce an error instead. |
registration | true | Whether this provider can create new Matrix accounts. Set to false to restrict the provider to existing users only. |
URL overrides
These override endpoints that are normally discovered automatically. Only use them for non-standard or undiscoverable providers.
| Field | Description |
|---|---|
authorization_url | Override the authorization endpoint. |
token_url | Override the token endpoint. |
revocation_url | Override the token revocation endpoint. |
introspection_url | Override the token introspection endpoint. |
userinfo_url | Override the userinfo endpoint. |
Session
| Field | Default | Description |
|---|---|---|
grant_session_duration | 300 | Seconds the authorization session stays valid before expiring (default: 5 minutes). |
check_cookie | true | Verify the redirect cookie during the callback for CSRF protection. Disable only if a reverse proxy strips cookies. |
Configuring via environment variables
For container deployments (Docker Compose, Podman, Kubernetes) where mounting a configuration file is inconvenient, every provider field can be set via environment variables instead.
The variable name is built from three parts joined by __ (double underscore):
TUWUNEL_IDENTITY_PROVIDER__<index>__<FIELD>
TUWUNEL_IDENTITY_PROVIDER— fixed prefix that maps to the[[global.identity_provider]]table array.<index>— an arbitrary string (typically0,1,2, …) that groups variables belonging to the same provider. All variables sharing the same index are treated as a single[[global.identity_provider]]entry. Indexes are sorted lexicographically, so numeric indexes give a predictable order.<FIELD>— the field name from the tables below, uppercased.
Multiple providers are expressed by using different indexes:
# First provider — GitHub
TUWUNEL_IDENTITY_PROVIDER__0__BRAND="github"
TUWUNEL_IDENTITY_PROVIDER__0__CLIENT_ID="Ov23liYourGitHubClientId"
TUWUNEL_IDENTITY_PROVIDER__0__CLIENT_SECRET="your_github_secret"
TUWUNEL_IDENTITY_PROVIDER__0__CALLBACK_URL="https://matrix.example.com/_matrix/client/unstable/login/sso/callback/Ov23liYourGitHubClientId"
# Second provider — Google (marked as default)
TUWUNEL_IDENTITY_PROVIDER__1__BRAND="google"
TUWUNEL_IDENTITY_PROVIDER__1__CLIENT_ID="123456789-abc.apps.googleusercontent.com"
TUWUNEL_IDENTITY_PROVIDER__1__CLIENT_SECRET="GOCSPX-your_secret"
TUWUNEL_IDENTITY_PROVIDER__1__CALLBACK_URL="https://matrix.example.com/_matrix/client/unstable/login/sso/callback/123456789-abc.apps.googleusercontent.com"
TUWUNEL_IDENTITY_PROVIDER__1__DEFAULT="true"
Every field listed in the tables below has a matching environment variable. For
example, trusted = true in TOML becomes
TUWUNEL_IDENTITY_PROVIDER__0__TRUSTED="true".
Example configurartions
GitHub
[[global.identity_provider]]
brand = "GitHub"
client_id = "Ov23liYourGitHubClientId"
client_secret = "your_github_client_secret"
callback_url = "https://matrix.example.com/_matrix/client/unstable/login/sso/callback/Ov23liYourGitHubClientId"
GitHub’s issuer_url and base_path are pre-configured. client_id doubles
as the provider ID in the callback URL.
[[global.identity_provider]]
brand = "Google"
client_id = "123456789-abc.apps.googleusercontent.com"
client_secret = "GOCSPX-your_secret"
callback_url = "https://matrix.example.com/_matrix/client/unstable/login/sso/callback/123456789-abc.apps.googleusercontent.com"
Self-hosted Keycloak
[[global.identity_provider]]
brand = "Keycloak"
client_id = "tuwunel"
client_secret = "your_keycloak_secret"
issuer_url = "https://sso.example.com/realms/myrealm"
callback_url = "https://matrix.example.com/_matrix/client/unstable/login/sso/callback/tuwunel"
trusted = true
With trusted = true, users whose Keycloak username matches an existing Matrix
localpart are granted access to that account. Only use trusted when you
control the identity provider.
Matrix Authentication Service (MAS)
[[global.identity_provider]]
brand = "MAS"
client_id = "your_mas_client_id"
client_secret = "your_mas_secret"
issuer_url = "https://auth.example.com"
callback_url = "https://matrix.example.com/_matrix/client/unstable/login/sso/callback/your_mas_client_id"
Common setup patterns
Linking existing users to an identity provider
When SSO is added to a server that already has password-based accounts, the central question is: how does Tuwunel know which provider identity belongs to which existing Matrix account?
The most direct approach is to set trusted = true and list "sub" in
userid_claims. The sub claim is the stable, globally unique user
identifier that every OIDC provider is required to maintain. Listing it in
userid_claims tells Tuwunel to use it as the authoritative match key.
Marking the provider as trusted then inverts Tuwunel’s normal logic: instead
of registering a new account when it finds no existing match, it looks for an
existing Matrix account whose localpart equals the sub value and grants
access to it. The result is that logging in through the provider seamlessly
picks up the user’s existing account:
[[global.identity_provider]]
brand = "Keycloak"
client_id = "tuwunel"
# ...
trusted = true
userid_claims = ["sub"]
For this to work, the sub value the provider returns for each user must
match that user’s Matrix localpart exactly. If your provider allows you to
set sub to an arbitrary value, aligning it with the Matrix localpart is
the cleanest path. If it does not — for example, if sub is an opaque UUID
— you can use a different claim (such as preferred_username) as the match
key, or pre-register the link with the admin command described below.
Only ever set trusted = true for identity providers you self-host and
fully control. In trusted mode, anyone who can present a matching provider
identity gains access to the corresponding Matrix account. Public providers
such as GitHub and Google must never be trusted.
Admin-approved association for untrusted providers
When the provider is not trusted — a public service such as GitHub or Google
where trusted = true would be unsafe — you can still link an existing
Matrix account to a specific provider identity by having an admin pre-approve
the connection before the user’s next login.
The admin command registers a set of claims to watch for from that provider. When the user next authenticates, Tuwunel checks the claims returned by the provider against any pending approvals. If every claim in the approval matches what the provider returns, the accounts are linked and the approval is consumed.
!admin query oauth associate <provider_id> @alice:example.com \
--claim sub=550e8400-e29b-41d4-a716-446655440000
Specify whichever claims uniquely identify the user on that provider. sub
is the most reliable because every OIDC provider guarantees it is stable and
unique per user.
Pending approvals are held in memory, not the database. The affected user must complete their login before the server is restarted, or the command must be run again.
How Tuwunel derives Matrix user IDs from claims
When a user authenticates through a provider for the first time and no existing account is linked, Tuwunel must compute a Matrix localpart from the claims in the provider’s userinfo response. It tries each of the following in order, using the first claim that is present and yields a valid, available username:
preferred_usernameusernamenicknameloginemail— only the portion before the@is used
The sub claim is deliberately excluded from this default sequence because
it is typically an opaque identifier rather than a human-readable name. It is
only consulted when explicitly listed in userid_claims, where it always
takes precedence over every other claim regardless of list order.
If none of these five claims appear in the userinfo response, or if every
derived candidate is already taken by another account, Tuwunel falls back to
a randomly generated localpart. This fallback is controlled by
unique_id_fallbacks, which defaults to true. On private servers where
silent random assignment is unacceptable, set it to false — Tuwunel will
return an error instead.
Make sure user profiles on your identity provider contain at least one of
the five claims above. If a profile has none of them, Tuwunel has nothing
to work with and will resort to the random fallback. The safest choice is to
ensure preferred_username is populated on every account, as it is the
first claim Tuwunel checks and tends to hold a recognisable, human-readable
name that also makes a reasonable Matrix localpart.
The userid_claims field lets you restrict which claims Tuwunel considers
and in what order. For example, to use only preferred_username and fall
back to the local part of the email address, and never silently generate a
random ID:
userid_claims = ["preferred_username", "email"]
unique_id_fallbacks = false
Listing "sub" anywhere in userid_claims elevates it to the highest
priority, overriding all other entries. The special value "unique" used
alone instructs Tuwunel to always generate a unique random localpart and
never attempt to derive one from claims at all.
Multiple providers
When multiple providers are configured, each appears separately on the
client’s login page (unless single_sso = true). The default field controls
which provider /_matrix/client/v3/login/sso/redirect (without a provider ID)
redirects to:
[[global.identity_provider]]
brand = "GitHub"
client_id = "github_client_id"
# ...
default = true # this provider handles the bare SSO redirect
[[global.identity_provider]]
brand = "Google"
client_id = "google_client_id"
# ...
If no provider is explicitly default and exactly one is configured, it
becomes the implicit default.
Global SSO options
These top-level options control how SSO providers are presented to clients.
| Option | Default | Description |
|---|---|---|
single_sso | false | (Experimental) Replace the provider list with a single “Sign in with single sign-on” button at /_matrix/client/v3/login/sso/redirect. All providers are attempted in sequence and all must succeed. |
sso_custom_providers_page | false | Replace the provider list with a single button and expect a reverse proxy to intercept /_matrix/client/v3/login/sso/redirect and serve a custom provider-selection page. Each entry on that page should link to /_matrix/client/v3/login/sso/redirect/<client_id>. |
oidc_aware_preferred | false | Advertise OIDC as the preferred login method (MSC3824). Clients that support next-gen auth will present it as the only option. |
Admin commands
These admin room commands help manage OAuth state:
| Command | Description |
|---|---|
!admin query oauth list-providers | List all configured providers and their provider_id. |
!admin query oauth list-users | List all users with an active OAuth session. |
!admin query oauth list-sessions [--user @user:example.com] | List session_id, optionally filtered by user. |
!admin query oauth show-provider <provider_id> | Show the active configuration for a provider. |
!admin query oauth show-user @user:example.com | Show OAuth sessions for a user. |
!admin query oauth associate <provider_id> @user:example.com --claim key=value | Associate an existing Matrix account with future OAuth claims from a provider. Useful for onboarding existing users to SSO. |
!admin query oauth revoke <session_id|@user:example.com> | Revoke tokens for a session or all sessions of a user. |
!admin query oauth delete <session_id|@user:example.com> | Remove OAuth state entirely (destructive). |
Protocol flow reference
- The client fetches
/_matrix/client/v3/loginand finds anm.login.ssoentry listing configured providers. - The user selects a provider; the client redirects to
/_matrix/client/v3/login/sso/redirect/<client_id>. - Tuwunel redirects the user to the provider’s authorization endpoint.
- The provider authenticates the user and redirects back to
/_matrix/client/unstable/login/sso/callback/<client_id>. - Tuwunel exchanges the code for tokens, fetches user claims, maps them to a Matrix user ID, and issues a login token back to the client.
Authelia
Authelia configuration
Add the client in Authelia’s config. The client secret in Authelia must be
stored as a hash; use the
Authelia CLI generator
to produce it (the hash always starts with $pbkdf2).
identity_providers:
oidc:
claims_policies:
tuwunel:
id_token: ["email", "name", "groups", "preferred_username"]
clients:
- client_id: '<client_id>'
client_name: 'tuwunel'
client_secret: '<client_secret_hash>'
claims_policy: "tuwunel"
public: false
redirect_uris:
- "https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id>"
scopes:
- 'openid'
- 'groups'
- 'email'
- 'profile'
grant_types:
- 'refresh_token'
- 'authorization_code'
response_types:
- 'code'
response_modes:
- 'form_post'
token_endpoint_auth_method: 'client_secret_post'
Tuwunel configuration
Note
The
client_secretvalue here is the plain-text password, not the hash stored in Authelia. Authelia stores the hash; Tuwunel supplies the password.
[[global.identity_provider]]
brand = "Authelia"
name = "Authelia"
client_id = "<client_id>"
client_secret = "<client_secret_password>"
issuer_url = "https://<your.authelia.example.com>"
callback_url = "https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id>"
See the Authelia OIDC documentation for full details on the provider side.
Authentik
Authentik is a self-hostable identity provider that speaks OpenID Connect.
Note
This guide was written against
Authentik 2025.10; the flow is the same on all later versions through at least2026.2.
Authentik configuration
From the admin interface, navigate to Applications > Applications and select Create with Provider. Review the items below and click Submit.
Application
- Application name: the user-facing name shown to your users on the Authentik side.
- Slug: appears in the issuer URL on the tuwunel side. Pick something
short and stable, such as
tuwunel.
Provider
Select OAuth2/OpenID Provider.
- Authorization flow: any built-in flow is fine if you have not created custom ones.
- Client ID and Client Secret: Authentik generates these. Save both values — tuwunel needs them.
- Redirect URIs/Origins: set this to
https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id>, substituting your Matrix server’s public hostname and the Client ID from the previous step. - All other fields can stay at their defaults.
Bindings
Optional. Add policies here to restrict tuwunel access to a subset of your Authentik users.
Tuwunel configuration
Add an [[global.identity_provider]] entry to your tuwunel.toml:
[[global.identity_provider]]
brand = "Authentik"
client_id = "<client_id>"
client_secret = "<client_secret>"
issuer_url = "https://<your.authentik.example.com>/application/o/<slug>/"
callback_url = "https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id>"
issuer_url is the application’s slug-based path with a trailing slash. This
is the value Authentik returns as the iss claim in issued tokens, and tuwunel
discovers all other endpoints from
<issuer_url>.well-known/openid-configuration automatically.
If your Authentik instance reports a different iss — for example when running
behind a path prefix, or with a non-default Issuer Mode on the provider —
override discovery directly:
discovery_url = "https://<your.authentik.example.com>/application/o/<slug>/.well-known/openid-configuration"
Tip
For the full set of identity provider options see the providers reference. For the Authentik side, see the OAuth2/OpenID Connect provider documentation.
Customising the Matrix localpart
By default tuwunel derives a new user’s Matrix localpart from the
preferred_username claim Authentik returns — see
How tuwunel derives Matrix user IDs from claims. The
default Authentik mapping populates preferred_username with the user’s
Authentik username, so user foo becomes @foo:example.com.
To decouple the two — for example to give Authentik user foo the localpart
@bar:example.com — replace Authentik’s default profile scope with a custom
property mapping that returns the localpart you want.
Create a custom property mapping
In the admin interface, navigate to Customization > Property Mappings
and select Create. Choose Scope Mapping, then set the Scope name to
profile.
In Expression, return a dictionary that exposes the desired localpart as
preferred_username. The example below uses a per-user matrix_localpart
attribute when set, falling back to the Authentik username:
if "matrix_localpart" in request.user.attributes:
return {
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.attributes["matrix_localpart"],
"nickname": request.user.attributes["matrix_localpart"],
"groups": [group.name for group in request.user.ak_groups.all()],
}
return {
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.username,
"nickname": request.user.username,
"groups": [group.name for group in request.user.ak_groups.all()],
}
Note the Name you give the mapping, then click Finish.
Replace the default profile mapping
In Applications > Providers, edit your tuwunel provider, expand Advanced protocol settings, and find the Scopes field.
Move your new mapping from Available Scopes into Selected Scopes with
the right arrow (>), then move authentik default OAuth Mapping: OpenID 'profile' out with the left arrow (<). Click Update.
Set the attribute on a user
In Directory > Users, edit the user and add to Attributes:
matrix_localpart: bar
Click Update.
Tip
Users can be allowed to set the attribute themselves through a custom prompt in a Stage Configuration flow. See Authentik’s documentation for details.
Verify
In Applications > Providers, open your provider and click Preview. Select the user under Preview for user; the JWT payload should contain the customised localpart:
{
"preferred_username": "bar",
"nickname": "bar"
}
Keycloak
Keycloak is a self-hostable OpenID Connect provider.
Keycloak configuration
-
Create a client on your Keycloak server:
- Enable Client Authentication.
- Set Root URL to
https://<your.matrix.example.com>. - Add a Valid Redirect URI:
https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id> - Set Web Origins to
https://<your.matrix.example.com>.
-
Navigate to the Credentials tab and note the Client Secret.
-
Note the realm you created the client in.
Tuwunel configuration
Important
Ensure your Matrix
.well-knownvalues are being served correctly before starting. You can verify them with matrixtest.
Add the following to your tuwunel.toml. Replace the placeholders with the
values from your Keycloak client.
[[global.identity_provider]]
brand = "Keycloak"
client_id = "<client_id_in_keycloak>"
client_secret = "<client_secret_from_credentials_tab>"
issuer_url = "https://<your.keycloak.example.com>/realms/<realm_name>"
callback_url = "https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id_in_keycloak>"
trusted = true
Setting trusted = true allows users whose Keycloak username matches an
existing Matrix localpart to log in to that account via SSO. Only use this
for identity providers you self-host and fully control — see
Linking existing users.
Environment variables
If you prefer environment variables (e.g. in docker-compose.yaml or a
tuwunel.env file):
TUWUNEL_IDENTITY_PROVIDER__0__BRAND="keycloak"
TUWUNEL_IDENTITY_PROVIDER__0__CLIENT_ID="<client_id>"
TUWUNEL_IDENTITY_PROVIDER__0__CLIENT_SECRET="<client_secret>"
TUWUNEL_IDENTITY_PROVIDER__0__ISSUER_URL="https://<your.keycloak.example.com>/realms/<realm_name>"
TUWUNEL_IDENTITY_PROVIDER__0__CALLBACK_URL="https://<your.matrix.example.com>/_matrix/client/unstable/login/sso/callback/<client_id>"
TUWUNEL_IDENTITY_PROVIDER__0__TRUSTED="true"
Multimedia and storage provision
Tuwunel handles media uploads, remote media fetching, thumbnail generation, URL previews, and blurhash generation. This chapter covers configuration for all of these features, as well as the storage backends that back them.
-
Storage providers — Local filesystem and S3-compatible object storage backends.
-
Media management — Commands for inspecting, deleting, and bulk-removing media, including spam response.
Upload limits
| Option | Default | Description |
|---|---|---|
max_request_size | 24 MiB | Maximum size of a single media upload. Accepts SI/IEC units, e.g. "50 MiB". |
max_pending_media_uploads | 5 | Maximum number of in-progress asynchronous uploads a single user can have at once. |
media_create_unused_expiration_time | 86400 | Seconds before an unused pending MXC URI is expired and removed (default: 24 hours). |
media_rc_create_per_second | 10 | Maximum media-create requests per second from a single user before rate limiting applies. |
media_rc_create_burst_count | 50 | Maximum burst size for media-create rate limiting per user. |
Legacy media endpoints
Matrix spec version 1.11 introduced authenticated media endpoints. The older unauthenticated endpoints are deprecated but some clients and servers still use them.
| Option | Default | Description |
|---|---|---|
allow_legacy_media | false | Serve the unauthenticated /_matrix/media/*/ endpoints locally. The authenticated equivalents are always enabled. |
request_legacy_media | false | Fall back to unauthenticated requests when fetching media from remote servers. Unauthenticated remote media was removed around 2024Q3; enabling this adds federation traffic that is unlikely to succeed. |
Blocking remote media
prevent_media_downloads_from is a list of regex patterns matched against
server names. Tuwunel refuses to download media originating from any matching
server.
prevent_media_downloads_from = [
"badserver\\.tld$",
"spammy-phrase",
]
This is useful as a reactive measure after a spam incident. See the Management page for bulk-deletion commands to pair with it.
URL previews
URL previews are disabled unless at least one allowlist is configured. All allowlist checks are evaluated before the denylist check.
| Option | Default | Description |
|---|---|---|
url_preview_domain_explicit_allowlist | [] | Exact domain matches allowed for previewing. "google.com" matches https://google.com but not https://subdomain.google.com. Set to ["*"] to allow all domains. |
url_preview_domain_contains_allowlist | [] | Substring domain matches. "google.com" matches any URL whose domain contains that string — including unrelated domains. Set to ["*"] to allow all domains. |
url_preview_url_contains_allowlist | [] | Substring match against the full URL (not just the domain). Set to ["*"] to allow all URLs. |
url_preview_domain_explicit_denylist | [] | Exact domain matches explicitly blocked. The denylist is checked first. Setting to ["*"] has no effect. |
url_preview_check_root_domain | false | When enabled, domain allowlist checks are applied to the root domain. Allows all subdomains of any allowed domain — e.g. allowing wikipedia.org also allows en.m.wikipedia.org. |
url_preview_max_spider_size | 256000 | Maximum bytes fetched from a URL when generating a preview (default: 256 KB). |
url_preview_bound_interface | — | Network interface name or IP address to bind when making URL preview requests. Example: "eth0" or "1.2.3.4". |
Note
Setting any allowlist to
["*"]opens significant attack surface — a malicious client could cause the server to make requests to arbitrary URLs on the local network. Use explicit allowlists wherever possible.
Blurhash
Tuwunel can generate blurhashes for uploaded images,
which clients use to show a blurred placeholder before the full image loads.
This requires the blurhashing compile-time feature.
Blurhash settings live in a dedicated config section:
[global.blurhashing]
components_x = 4
components_y = 3
blurhash_max_raw_size = 33554432
| Option | Default | Description |
|---|---|---|
components_x | 4 | Horizontal detail components. Higher values produce more detailed hashes at the cost of a larger hash string. |
components_y | 3 | Vertical detail components. |
blurhash_max_raw_size | 33554432 | Maximum raw image size (after decoding to pixel data) that will be blurhashed, in bytes (default: ~32 MiB). Set to 0 to disable blurhashing entirely. Should be at or above max_request_size to avoid silently skipping large uploads. |
Storage providers
Tuwunel stores media through a configurable provider layer that abstracts over local filesystem and S3-compatible object storage. Multiple providers can be active simultaneously, which enables zero-downtime migrations.
Default storage
Without any explicit configuration, Tuwunel stores media in a subdirectory
called media/ inside your database_path. This is represented internally
as the implicit provider named "media".
# These are the effective defaults — no configuration required
media_storage_providers = ["media"]
store_media_on_providers = []
When store_media_on_providers is empty, all providers in
media_storage_providers receive new uploads. With only the implicit
"media" provider active, this is simply the local filesystem.
Configuring providers
Providers are declared as TOML sections named
[global.storage_provider.<NAME>.<brand>], where <NAME> is the identifier
you reference in media_storage_providers, and <brand> is either local
or s3. For container deployments (Docker Compose, Podman, Kubernetes) where
mounting a configuration file is inconvenient, please refer to the section on
environment variables instead.
Local filesystem
[global.storage_provider.media.local]
base_path = "/var/lib/tuwunel/media"
create_if_missing = false
delete_empty_directories = true
startup_check = true
| Field | Default | Description |
|---|---|---|
base_path | required | Absolute path to the storage directory. |
create_if_missing | false | Create the directory if it does not exist. Disabled by default to surface misconfiguration rather than silently creating a wrong path. |
delete_empty_directories | true | Remove directories that become empty after a file is deleted. |
startup_check | true | Verify the directory is accessible at startup. Failure aborts startup. |
S3 and S3-compatible storage
[global.storage_provider.media_on_s3.s3]
bucket = "my-matrix-media"
region = "us-east-1"
key = "AKIAIOSFODNN7EXAMPLE"
secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
Alternatively, supply a full S3 URL that encodes bucket, region, and path:
[global.storage_provider.media_on_s3.s3]
url = "s3://my-matrix-media/prefix"
key = "AKIAIOSFODNN7EXAMPLE"
secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
S3 configuration reference
| Field | Default | Description |
|---|---|---|
url | — | S3 URL of the form s3://bucket/path. Components not present in the URL can be set individually below. |
bucket | — | Bucket name. |
region | us-east-1 | AWS region where the bucket resides. |
key | — | IAM Access Key ID. |
secret | — | IAM Secret Access Key. Not logged or serialized. |
token | — | Session token for temporary credentials. Not logged or serialized. |
base_path | — | Path prefix inside the bucket. All objects are stored under this prefix. |
endpoint | — | Override the S3 endpoint URL. Required for self-hosted S3-compatible services such as MinIO or DigitalOcean Spaces. |
multipart_threshold | 100 MiB | Files at or above this size use the S3 multipart upload API. Accepts SI/IEC unit strings. |
kms | — | SSE-KMS key ARN for server-side encryption. |
use_bucket_key | — | Enable S3 Bucket Keys for KMS encryption. Should match the bucket setting. |
use_vhost_request | — | Override virtual-hosted-style request path. Derived automatically from the URL by default. |
use_https | true | Require HTTPS. Set false only for local development with HTTP-only test endpoints. |
startup_check | true | Ping the bucket at startup to confirm connectivity. Failure aborts startup. Set false if the provider may be unavailable during startup. |
Self-hosted S3-compatible services
For MinIO, DigitalOcean Spaces, Cloudflare R2, and similar services, set the
endpoint field and disable virtual-hosted-style requests if required:
[global.storage_provider.media_on_s3.s3]
endpoint = "https://minio.example.com:9000"
bucket = "matrix-media"
region = "us-east-1"
key = "minioadmin"
secret = "minioadmin"
use_vhost_request = false
Environment variables
The variable name is built from four parts joined by __ (double underscore):
TUWUNEL_STORAGE_PROVIDER__<NAME>__<brand>__<FIELD>
TUWUNEL_STORAGE_PROVIDER— fixed prefix that maps to[global.storage_provider].<NAME>— the provider name you reference inmedia_storage_providers(e.g.,MEDIA,MEDIA_ON_S3).<brand>— the provider type:LOCALorS3.<FIELD>— the field name from the tables below, uppercased.
Local filesystem example
TUWUNEL_STORAGE_PROVIDER__MEDIA__LOCAL__BASE_PATH="/var/lib/tuwunel/media"
TUWUNEL_STORAGE_PROVIDER__MEDIA__LOCAL__CREATE_IF_MISSING="false"
S3 example
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__BUCKET="my-matrix-media"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__REGION="us-east-1"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__KEY="AKIAIOSFODNN7EXAMPLE"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__SECRET="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
Self-hosted S3-compatible example
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__ENDPOINT="https://minio.example.com:9000"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__BUCKET="matrix-media"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__REGION="us-east-1"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__KEY="minioadmin"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__SECRET="minioadmin"
TUWUNEL_STORAGE_PROVIDER__MEDIA_ON_S3__S3__USE_VHOST_REQUEST="false"
The media_storage_providers and store_media_on_providers lists are
top-level settings and follow the standard env var pattern using TOML array
syntax:
TUWUNEL_MEDIA_STORAGE_PROVIDERS='["media", "media_on_s3"]'
TUWUNEL_STORE_MEDIA_ON_PROVIDERS='["media_on_s3"]'
Migrating to a new storage provider
To migrate from local storage to S3 (or between any two providers) without downtime:
Step 1 — Add the new provider and list both in media_storage_providers,
but direct new writes only to the new one via store_media_on_providers:
media_storage_providers = ["media", "media_on_s3"]
store_media_on_providers = ["media_on_s3"]
[global.storage_provider.media_on_s3.s3]
bucket = "my-matrix-media"
region = "us-east-1"
key = "AKIAIOSFODNN7EXAMPLE"
secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
Tuwunel now writes new media to S3 and reads from whichever provider holds the file, falling back to local if not found on S3.
Step 2 — Copy existing files to the new provider using the storage sync admin command:
!admin query storage sync media media_on_s3
This copies all objects present in media but absent from media_on_s3.
Step 3 — Once the sync is complete and verified, remove "media" from
both lists and restart:
media_storage_providers = ["media_on_s3"]
store_media_on_providers = []
Storage admin commands
These commands are available via !admin query storage and operate directly
on provider objects. They are useful for diagnostics, manual migrations, and
verifying provider state.
| Command | Description |
|---|---|
!admin query storage configs | List all configured storage provider configurations. |
!admin query storage providers | List all active storage provider instances. |
!admin query storage debug [<provider>] | Print debug information for a provider. |
!admin query storage show [-p <provider>] <path> | Show object metadata for a given path. |
!admin query storage list [-p <provider>] [<prefix>] | List objects under an optional prefix. |
!admin query storage copy [-p <provider>] [-f] <src> <dst> | Copy an object. -f overwrites an existing destination. |
!admin query storage move [-p <provider>] [-f] <src> <dst> | Move (rename) an object. |
!admin query storage delete [-p <provider>] [-v] <path>… | Delete one or more objects. -v prints each deletion. |
!admin query storage duplicates <src_provider> <dst_provider> | List objects that exist in both providers. |
!admin query storage differences <src_provider> <dst_provider> | List objects present in one provider but not the other. |
!admin query storage sync <src_provider> <dst_provider> | Copy all objects from src that are missing in dst. |
Startup checks
These options control what Tuwunel verifies about stored media at startup.
| Option | Default | Description |
|---|---|---|
media_startup_check | true | Scan the media directory at startup. Removes database entries for files that no longer exist on disk (when prune_missing_media is enabled), and upgrades Conduit-era symlinks (when media_compat_file_link is enabled). Disable if startup is slow due to a large media directory and neither check applies to you. |
prune_missing_media | false | During the startup scan, delete database metadata for any media file that is missing from disk. Caution: if the storage directory is temporarily inaccessible or miss-mounted, this will permanently destroy metadata for all affected files. |
media_compat_file_link | false | Create Conduit-compatible symlinks alongside Tuwunel’s media files. Only needed if you intend to switch back to Conduit. Requires media_startup_check = true to take effect. |
Media management
Tuwunel provides a set of admin room commands for inspecting and deleting
media. All commands are invoked in the admin room and prefixed with
!admin media.
Inspecting media
get-file-info
!admin media get-file-info <mxc_uri>
Returns the stored metadata for a media file: content type, file size, creation time, and which user uploaded it. Useful for investigating a reported file before deciding whether to delete it.
get-remote-file
!admin media get-remote-file <mxc_uri> [-s <server>] [-t <timeout_ms>]
Fetches a remote media file from the originating server and returns its metadata. The actual content is discarded after fetching so the admin room is not flooded. Default timeout is 10 000 ms.
get-remote-thumbnail
!admin media get-remote-thumbnail <mxc_uri> \
[-s <server>] [-t <timeout_ms>] [--width <px>] [--height <px>]
Like get-remote-file but requests a thumbnail at the given dimensions
(default 800×800). Useful for confirming what a thumbnail looks like without
sending it to a client.
Deleting media
Delete a single file
!admin media delete --mxc mxc://example.com/AbCdEfGhIjKl
Removes one media file from the database and from storage. Use
get-file-info first to confirm you have the right MXC URI.
Delete media referenced by an event
!admin media delete-by-event --event-id $abc123:example.com
Extracts all MXC URIs from the event (including the primary media URL, thumbnail URL, and encrypted file URL) and deletes each one. Returns the number of files deleted. Useful when a user reports a specific message containing unwanted media.
Delete a list of MXC URIs
Paste a code block of MXC URIs into the admin room, one per line:
!admin media delete-list
```
mxc://example.com/AbCdEfGhIjKl
mxc://example.com/MnOpQrStUvWx
mxc://badserver.tld/YzAbCdEfGhIj
```
Errors on individual URIs are ignored. The command returns the total number deleted and the number that failed to parse.
Delete by time range
!admin media delete-range <duration> --older-than
!admin media delete-range <duration> --newer-than
Deletes remote media whose filesystem modification time falls outside the
given duration from now. Exactly one of --older-than or --newer-than
must be specified.
Duration format: 30s, 5m, 2h, 7d, etc.
By default only remote media is deleted. To also delete locally-uploaded media, append the confirmation flag:
!admin media delete-range 90d --older-than --yes-i-want-to-delete-local-media
Examples:
# Delete all remote media older than 30 days
!admin media delete-range 30d --older-than
# Delete remote media uploaded in the last hour (e.g. after a spam burst)
!admin media delete-range 1h --newer-than
Delete all media from a local user
!admin media delete-all-from-user <username>
Deletes every media file uploaded by the named local user. The username is
the localpart only, without the @ or server name. Errors on individual
files are ignored.
Delete all media from a remote server
!admin media delete-all-from-server <server_name>
Deletes every cached copy of remote media originating from the given server. This only affects remotely-fetched media by default. To also remove local uploads that somehow reference the server, add the confirmation flag:
!admin media delete-all-from-server <server_name> \
--yes-i-want-to-delete-local-media
Responding to a spam incident
The full incident playbook (cached-media delete, federation blocks, invite gating, and the per-room policy-server option) lives in Policy and Moderation > Responding to a spam incident. That chapter is the source of truth; refer to it when an incident is in progress.
Voice and videotelephony systems
There are two ways of setting up voice and video calling for use with Matrix:
- MatrixRTC/Element Call (using a Livekit backend).
- Legacy Calling (using a TURN backend).
Which of these is right for your homeserver largely depends on your preferred client. Different clients support different calling methods, but the majority of maintained clients that support calling are moving towards using MatrixRTC. It is also possible to use legacy calls and the newer MatrixRTC concurrently.
To set up MatrixRTC/Element Call, see the MatrixRTC documentation.
To set up legacy calling, see the TURN documentation. Note: if you are also setting up MatrixRTC, additionally review the External TURN Integration section of the MatrixRTC documentation.
MatrixRTC/Element Call Setup
MatrixRTC is the modern Matrix calling framework used by Element Call and other recent clients. The media itself is carried by a Selective Forwarding Unit (SFU); Tuwunel does not embed an SFU, so an external one is required. Livekit is the SFU implementation supported here, paired with the lk-jwt-service which issues the access tokens clients use to join Livekit rooms.
This guide shows you how to deploy MatrixRTC/Element Call using Docker and Docker Compose, as Livekit only provides prebuilt Docker images. It is possible to run Livekit using their installation script, however this method is not supported or recommended.
Note
In the following documentation,
yourdomain.comis whatever you have set asserver_namein yourtuwunel.toml. This needs to be replaced with the actual domain. It is assumed that you will be hosting MatrixRTC atmatrix-rtc.yourdomain.com. If you wish to host this service at a different subdomain, this needs to be replaced as well.
Note
This guide provides example configuration for Caddy, Nginx, and Traefik reverse proxies. Others can be used, but the configuration will need to be adapted.
1. Set Up DNS
Create a DNS record for matrix-rtc.yourdomain.com pointing to your server.
2. Initial Setup
- Create a directory for your MatrixRTC setup,
e.g.
mkdir /opt/matrix-rtc. - Change into that directory, e.g.
cd /opt/matrix-rtc. - The following steps will require a key and a secret, referred to as
MRTCKEYandMRTCSECREThereafter. It is suggested thatMRTCKEYis 20 characters andMRTCSECRETis 64 characters. If you havepwgeninstalled, you can generate these withpwgen -s -1 20forMRTCKEYandpwgen -s -1 64forMRTCSECRET. Make a note of these values for use in later steps.
2.1 Create Docker Compose Containers
Note: If you are using plain Docker rather than Docker Compose, skip to step 2.2.
- Create and open a
compose.yamlfile in your MatrixRTC directory, e.g.nano compose.yaml. - Add the following.
MRTCKEYandMRTCSECRETshould be replaced with the values generated above.
services:
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
container_name: matrix-rtc-jwt
environment:
- LIVEKIT_JWT_BIND=:8081
- LIVEKIT_URL=wss://matrix-rtc.yourdomain.com
- LIVEKIT_KEY=MRTCKEY # Random 20 character string
- LIVEKIT_SECRET=MRTCSECRET # Random 64 character string
- LIVEKIT_FULL_ACCESS_HOMESERVERS=yourdomain.com # Your server_name from tuwunel.toml
restart: unless-stopped
ports:
- "8081:8081"
matrix-rtc-livekit:
image: livekit/livekit-server:latest
container_name: matrix-rtc-livekit
command: --config /etc/livekit.yaml
restart: unless-stopped
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
network_mode: "host"
# Uncomment the lines below and comment `network_mode: "host"` above to specify port mappings.
# ports:
# - "7880:7880/tcp"
# - "7881:7881/tcp"
# - "50100-50200:50100-50200/udp"
2.2 Create Livekit Configuration
- Create and open a
livekit.yamlfile in your MatrixRTC directory, e.g.nano livekit.yaml. - Add the following.
MRTCKEYandMRTCSECRETshould be replaced with the values generated above.
port: 7880
bind_addresses:
- ""
rtc:
tcp_port: 7881
port_range_start: 50100
port_range_end: 50200
use_external_ip: true
enable_loopback_candidate: false
keys:
MRTCKEY: MRTCSECRET
Note
The
enable_loopback_candidateoption above causes Livekit to include127.0.0.1and::1as ICE host candidates. It is intended for deployments where the public IP is mapped to the loopback interface on the host. Set it totrueif you have that specific topology. If calls fail only for clients on the same LAN as the server, see the Troubleshooting section.
3. Configure .well-known
3.1. .well-known served by Tuwunel
Follow this step if your .well-known configuration is served by Tuwunel.
Otherwise follow Step 3.2.
- Open your
tuwunel.tomlfile, e.g.nano /etc/tuwunel/tuwunel.toml. - Find the line reading
#livekit_url = ""and replace it with:
livekit_url = "https://matrix-rtc.yourdomain.com"
- Ensure that you have
[global.well_known]uncommented above this line..well-knownwill not be served correctly otherwise.
3.2. .well-known served independently
Follow this step if you serve your .well-known/matrix files directly.
Otherwise follow Step 3.1.
- Open your
.well-known/matrix/clientfile, e.g.nano /var/www/.well-known/matrix/client. - Add the following to the end of this file:
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.yourdomain.com"
}
]
The final file should look something like this:
{
"m.homeserver": {
"base_url":"https://matrix.yourdomain.com"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.yourdomain.com"
}
]
}
4. Configure Firewall
You will need to allow ports 7881/tcp and 50100:50200/udp through your
firewall. If you use UFW: ufw allow 7881/tcp and
ufw allow 50100:50200/udp.
If you are behind NAT, you will also need to forward 7880/tcp, 7881/tcp,
and 50100:50200/udp to livekit.
5. Configure Reverse Proxy
As reverse proxies can be installed in different ways, step-by-step instructions are not given for this section. If you use Caddy, follow step 5.1; Nginx, follow step 5.2; Traefik, follow step 5.3.
5.1. Caddy
- Add the following to your Caddyfile. If you are running Caddy in Docker,
replace
localhostwithmatrix-rtc-jwtin the first instance, andmatrix-rtc-livekitin the second.
matrix-rtc.yourdomain.com {
# This is matrix-rtc-jwt
@jwt_service {
path /sfu/get* /healthz* /get_token*
}
handle @jwt_service {
reverse_proxy localhost:8081
}
# This is livekit
handle {
reverse_proxy localhost:7880 {
header_up Connection "upgrade"
header_up Upgrade {http.request.header.Upgrade}
}
}
}
- Restart Caddy.
5.2. Nginx
- Add the following to your Nginx configuration. If you are running Nginx
in Docker, replace
localhostwithmatrix-rtc-jwtin the first instance, andmatrix-rtc-livekitin the second.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name matrix-rtc.yourdomain.com;
# Logging
access_log /var/log/nginx/matrix-rtc.yourdomain.com.log;
error_log /var/log/nginx/matrix-rtc.yourdomain.com.error;
# TLS example for certificate obtained from Let's Encrypt.
ssl_certificate /etc/letsencrypt/live/matrix-rtc.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix-rtc.yourdomain.com/privkey.pem;
# lk-jwt-service
location ~ ^/(sfu/get|healthz|get_token) {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# livekit
location / {
proxy_pass http://localhost:7880;
proxy_http_version 1.1;
proxy_set_header Connection "upgrade";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Optional timeouts per LiveKit
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
- Restart Nginx.
5.3. Traefik
- Add
matrix-rtc-jwtandmatrix-rtc-livekitto your Traefik network:
services:
matrix-rtc-jwt:
# ...
networks:
- proxy # your traefik network name
matrix-rtc-livekit:
# ...
networks:
- proxy # your traefik network name
networks:
proxy: # your traefik network name
external: true
- Configure routing with either of the methods below.
2.1 Labels
services:
matrix-rtc-jwt:
# ...
labels:
- "traefik.enable=true"
- "traefik.http.routers.matrixrtcjwt.entrypoints=websecure"
- "traefik.http.routers.matrixrtcjwt.rule=Host(`matrix-rtc.yourdomain.com`) && (PathPrefix(`/sfu/get`) || PathPrefix(`/healthz`) || PathPrefix(`/get_token`))"
- "traefik.http.routers.matrixrtcjwt.tls=true"
- "traefik.http.routers.matrixrtcjwt.service=matrixrtcjwt"
- "traefik.http.services.matrixrtcjwt.loadbalancer.server.port=8081"
- "traefik.http.routers.matrixrtcjwt.tls.certresolver=yourcertresolver" # change to your cert resolver's name
- "traefik.docker.network=proxy" # your traefik network name
matrix-rtc-livekit:
# ...
labels:
- "traefik.enable=true"
- "traefik.http.routers.livekit.entrypoints=websecure"
- "traefik.http.routers.livekit.rule=Host(`matrix-rtc.yourdomain.com`)"
- "traefik.http.routers.livekit.tls=true"
- "traefik.http.routers.livekit.service=livekit"
- "traefik.http.services.livekit.loadbalancer.server.port=7880"
- "traefik.http.routers.livekit.tls.certresolver=yourcertresolver" # change to your cert resolver's name
- "traefik.docker.network=proxy" # your traefik network name
2.2 Config file
http:
routers:
matrixrtcjwt:
entryPoints:
- "websecure"
rule: "Host(`matrix-rtc.yourdomain.com`) && (PathPrefix(`/sfu/get`) || PathPrefix(`/healthz`) || PathPrefix(`/get_token`))"
tls:
certResolver: "yourcertresolver" # change to your cert resolver's name
service: matrixrtcjwt
livekit:
entryPoints:
- "websecure"
rule: "Host(`matrix-rtc.yourdomain.com`)"
tls:
certResolver: "yourcertresolver" # change to your cert resolver's name
service: livekit
services:
matrixrtcjwt:
loadBalancer:
servers:
- url: "http://matrix-rtc-jwt:8081"
passHostHeader: true
livekit:
loadBalancer:
servers:
- url: "http://matrix-rtc-livekit:7880"
passHostHeader: true
6. Start Docker Containers
Note: If you are using Docker Compose, follow 6.1. If you are using plain Docker, follow 6.2.
6.1 Using Docker Compose
- Ensure you are in your matrix-rtc directory, e.g.
cd /opt/matrix-rtc. - Start containers:
docker compose up -d.
6.2 Using Docker Run
- Start
matrix-rtc-jwt.MRTCKEYandMRTCSECRETshould be replaced with the values generated in Step 2.matrix-rtc.yourdomain.comshould be replaced with your MatrixRTC subdomain.yourdomain.comshould be replaced with what you have set asserver_nameintuwunel.toml.
docker run -d \
--restart unless-stopped \
--name matrix-rtc-jwt \
-p 8081:8081 \
-e LIVEKIT_JWT_BIND=":8081" \
-e LIVEKIT_URL="wss://matrix-rtc.yourdomain.com" \
-e LIVEKIT_KEY="MRTCKEY" \
-e LIVEKIT_SECRET="MRTCSECRET" \
-e LIVEKIT_FULL_ACCESS_HOMESERVERS="yourdomain.com" \
ghcr.io/element-hq/lk-jwt-service:latest
- Start
matrix-rtc-livekit:
docker run -d \
--restart unless-stopped \
--name matrix-rtc-livekit \
--network host \
-v /opt/matrix-rtc/livekit.yaml:/etc/livekit.yaml:ro \
livekit/livekit-server:latest \
--config /etc/livekit.yaml
Element Call should now be working.
Additional Configuration
External TURN Integration
If you follow this guide and also set up Coturn as per the
TURN documentation, there will be a port clash between the two
services. To avoid this, the following must be added to your coturn.conf:
min-port=50201
max-port=65535
If you have Coturn configured, you can use it as a TURN server for Livekit
to improve call reliability. As Coturn allows multiple instances of
static-auth-secret, it is suggested that the secret used for Livekit is
different to that used for Tuwunel.
- Create a secret for Coturn — a random 64-character alphanumeric string is suggested.
- Add the following line to the end of your
coturn.conf, whereAUTH_SECRETis the secret created in Step 1:
static-auth-secret=AUTH_SECRET
- Add the following to the end of the
rtcblock in yourlivekit.yaml.AUTH_SECRETis the same as above.turn.yourdomain.comshould be replaced with your actual TURN domain.
turn_servers:
- host: turn.yourdomain.com
port: 5349
protocol: tls
secret: "AUTH_SECRET"
Using the Livekit Built-In TURN Server
Livekit includes a built-in TURN server which can be used in place of an external option. This TURN server will only work with Livekit and is not compatible with traditional Matrix calling. For that, see the TURN documentation.
Basic Setup
The simplest way to enable this is to add the following to your livekit.yaml:
turn:
enabled: true
udp_port: 3478
relay_range_start: 50300
relay_range_end: 65535
domain: matrix-rtc.yourdomain.com
It is strongly recommended that you use network_mode: "host"; however if
port mappings are necessary, add the following ports to matrix-rtc-livekit
in your compose.yaml:
ports:
- 3478:3478/udp
- 50300-65535:50300-65535/udp
You will need to allow ports 3478 and 50300:65535/udp through your
firewall. If you use UFW: ufw allow 3478 and ufw allow 50300:65535/udp.
Setup With TLS
To enable TLS for the TURN server, the process is slightly more complicated. Some WebRTC software will not accept certificates provided by Let’s Encrypt; it is therefore suggested that you use ZeroSSL as an alternative.
- Create a DNS record for e.g.
matrix-turn.yourdomain.compointing to your server. - Get a certificate for this subdomain.
- Add the certificates as volumes for
matrix-rtc-livekitin yourcompose.yaml. For example:
volumes:
- ./certs/privkey.pem:/certs/privkey.pem:ro
- ./certs/fullchain.pem:/certs/fullchain.pem:ro
- Add the following to the bottom of your
livekit.yaml. The values forcert_fileandkey_fileshould match where these files are mounted in the container.
turn:
enabled: true
udp_port: 3478
tls_port: 5349
relay_range_start: 50300
relay_range_end: 65535
external_tls: false
domain: matrix-turn.yourdomain.com
cert_file: /certs/fullchain.pem
key_file: /certs/privkey.pem
- It is strongly recommended that you use
network_mode: "host"; however if port mappings are necessary, add the following ports tomatrix-rtc-livekitin yourcompose.yaml:
ports:
- 3478:3478/udp
- 5349:5349/tcp
- 50300-65535:50300-65535/udp
- You will need to allow ports
3478,5349, and50300:65535/udpthrough your firewall. If you use UFW:ufw allow 3478,ufw allow 5349, andufw allow 50300:65535/udp. - Restart the containers.
Troubleshooting
The easiest way to test your configuration is using the testmatrix utility
provided by spaetz. This can be
installed using pip install testmatrix if you have Python and pip installed.
To use this utility to test your call setup, you will need an access token for your account. This can be most easily found at the bottom of the “Help & About” section of the Element Web settings, or in the “Developer Tools” section of the Cinny settings.
Once you have testmatrix installed, run the following (YOUR_TOKEN must
be replaced with the access token from your client described above):
testmatrix -u @your-user:yourdomain.com -t YOUR_TOKEN yourdomain.com
The output of this command will give you information on whether calls are properly set up.
If all tests are successful, you will get credentials that can be used with the Livekit Connection Tester. This can be used to test the ability of your Livekit service to route calls.
If any of these tests fail, further information can be found in the container
logs. Run docker compose logs --follow in the directory where your
compose.yaml is located.
Calls connect for some clients but not others
A common failure mode is that calls work for clients outside your local network but fail for clients on the same LAN as the Livekit server, and the failure is browser-dependent (Firefox often breaks while Chromium succeeds, or vice versa).
This is usually a NAT routing issue rather than a Livekit configuration issue. Livekit advertises the server’s public IP as an ICE candidate via STUN. Clients on the same LAN that try to reach that public IP must traverse the router back into the LAN, a feature called “NAT loopback” or “hairpin NAT”. Some routers do not support it, and in that case local clients cannot establish a media path to any candidate that names the public IP. Browsers differ in how aggressively they retry alternative candidates and in how they handle host candidates under mDNS obfuscation, so the same network can break one browser while another works.
The reliable fix is to make local clients resolve the MatrixRTC subdomain to the LAN address, so they bypass the router for that traffic. Most routers expose this as a “local DNS override”, “DNS rewrite”, or “host override” setting. Alternatively, run a small local DNS resolver (dnsmasq, AdGuard Home, Pi-hole) on the network and point LAN clients at it. External clients continue to resolve the subdomain to the public IP via public DNS.
Setting up TURN/STUN
In order to make or receive legacy calls in Matrix, a TURN server is required. Tuwunel suggests using Coturn for this purpose, which is also available as a Docker image.
Note
If you are setting up MatrixRTC and only need a TURN server to improve Livekit call reliability (not legacy calling), Livekit ships its own built-in TURN server. See Using the Livekit Built-In TURN Server in the MatrixRTC documentation. The instructions on this page are for a standalone Coturn deployment used by legacy Matrix calls; if you run both, review External TURN Integration for the port allocation considerations.
Configuration
Create a configuration file called coturn.conf containing:
use-auth-secret
static-auth-secret=<a secret key>
realm=<your server domain>
A common way to generate a suitable alphanumeric secret key is by using
pwgen -s 64 1.
These same values need to be set in Tuwunel. See the example config in the TURN section for configuring these and restart Tuwunel after.
turn_secret or a path to turn_secret_file must have a value of your
coturn static-auth-secret, or use turn_username and turn_password
if using legacy username:password TURN authentication (not preferred).
turn_uris must be the list of TURN URIs you would like to send to the
client. Typically you will just replace the example domain example.turn.uri
with the realm you set from the example config.
If you are using TURN over TLS, you can replace turn: with turns: in the
turn_uris config option to instruct clients to attempt to connect to TURN
over TLS. This is highly recommended.
If you need unauthenticated access to the TURN URIs, or some clients may be
having trouble, you can enable turn_guest_access in Tuwunel which disables
authentication for the TURN URI endpoint
/_matrix/client/v3/voip/turnServer.
Run
Run the Coturn image using
docker run -d --network=host \
-v $(pwd)/coturn.conf:/etc/coturn/turnserver.conf \
coturn/coturn
or docker-compose. For the latter, paste the following section into a file
called docker-compose.yml and run docker compose up -d in the same
directory.
services:
turn:
container_name: coturn-server
image: docker.io/coturn/coturn
restart: unless-stopped
network_mode: "host"
volumes:
- ./coturn.conf:/etc/coturn/turnserver.conf
To understand why the host networking mode is used and explore alternative configuration options, please visit Coturn’s Docker documentation.
For security recommendations see Synapse’s Coturn documentation.
The TURN server requires the appropriate ports to be forwarded where your
installation is behind NAT. These are currently 3478/tcp, 3478/udp,
5349/tcp, and 5349/udp.
Application Services
Application services are out-of-process programs that extend the homeserver; most bridges (IRC, XMPP, Discord, Telegram, Slack) are implemented this way. They authenticate with a pair of shared secret tokens and declare namespaces of users and room aliases that they manage.
The upstream reference is the Matrix Application Service API specification.
Registration
Tuwunel supports three registration methods. They coexist and are all loaded on startup.
Admin room
The most convenient method for live deployments. Send !admin appservices register to the admin room with the registration YAML in a code block below:
!admin appservices register
\`\`\`
id: my-bridge
url: http://localhost:29328
as_token: secret-token-a
hs_token: secret-token-b
sender_localpart: my-bridge-bot
namespaces:
users:
- exclusive: true
regex: '@my-bridge_.*:example\.com'
\`\`\`
The registration is persisted in the database and survives restarts. Registering with an existing ID replaces the old entry. No restart is required.
Server configuration
Application services can be declared inline in tuwunel.toml. The TOML section key becomes the registration ID unless id is explicitly set:
[global.appservice.my-bridge]
url = "http://localhost:29328"
as_token = "secret-token-a"
hs_token = "secret-token-b"
sender_localpart = "my-bridge-bot"
[[global.appservice.my-bridge.users]]
exclusive = true
regex = '@my-bridge_.*:example\.com'
Config-file registrations are reloaded on each restart. They cannot be removed with !admin appservices unregister.
Registration file directory
Point appservice_dir at a directory containing YAML registration files. Every readable file is loaded at startup:
[global]
appservice_dir = "/etc/tuwunel/appservices"
Files use the same YAML format that bridges produce for Synapse. New or removed files are not picked up without a restart.
Namespaces
Each registration declares which users, room aliases, and room IDs the appservice claims. Entries under users, aliases, and rooms are lists; each entry has a regex and an optional exclusive flag.
| Namespace | Matches | Case |
|---|---|---|
users | User IDs (@localpart:server) | Case-insensitive |
aliases | Room aliases (#alias:server) | Case-insensitive |
rooms | Room IDs (!opaque:server) | Case-sensitive |
Regex matching is unanchored – add ^ and $ if you need to match the full string.
When exclusive: true, the homeserver rejects any attempt by a normal user to register a conflicting user ID or room alias. Multiple appservices can share a non-exclusive namespace; exclusive ranges must not overlap.
Configuration reference
Fields under [global.appservice.<ID>]:
| Field | Default | Description |
|---|---|---|
id | section key | Unique registration ID. Inferred from the TOML section key when omitted. |
url | – | Base URL the homeserver pushes events to. Set to null for receive-only registrations. |
as_token | – | Token the appservice sends to the homeserver in Authorization headers. |
hs_token | – | Token the homeserver sends to the appservice on every push. |
sender_localpart | id | Localpart of the appservice bot user. Defaults to the registration ID. |
rate_limited | false | Whether requests from masqueraded virtual users are rate-limited. The bot user is always exempt. |
protocols | [] | Protocols bridged (e.g. ["irc"]). Reported via the /thirdparty/protocols endpoint. |
receive_ephemeral | false | Include ephemeral events (typing notifications, read receipts) in pushes to the appservice. |
device_management | false | Allow the appservice to manage devices on behalf of virtual users (MSC4190). |
Namespace entries under [[global.appservice.<ID>.users]], aliases, and rooms:
| Field | Default | Description |
|---|---|---|
exclusive | false | Claim exclusive ownership of matching IDs, blocking regular users from registering them. |
regex | – | Regular expression. Unanchored by default. |
Admin commands
All commands run from the admin room (!admin appservices <subcommand>):
| Command | Description |
|---|---|
register | Register or replace an appservice. Paste the YAML in a code block below the command. |
unregister <id> | Remove a database-registered appservice and cancel pending deliveries. Config-file registrations cannot be unregistered this way. |
show-config <id> | Print the stored registration as YAML. |
list | List IDs of all loaded appservices. |
Connection settings
These options go in the top-level [global] section:
| Option | Default | Description |
|---|---|---|
appservice_timeout | 35 | Request timeout in seconds when pushing events to an appservice. |
appservice_idle_timeout | 300 | Idle connection pool timeout in seconds. |
dns_passthru_appservices | false | Bypass DNS passthru domain matching for all appservice URLs. More efficient than listing each domain in dns_passthru_domains when all appservices share the same network. |
Getting help
For setup questions, join #tuwunel:matrix.org or open an issue.
Policy and Moderation
This chapter covers the federation-level, room-level, and user-level controls Tuwunel exposes to operators for keeping their server, its users, and the rooms they participate in within a desired policy posture. Most controls fall into one of three layers: configuration (statically applied at startup or reload), admin-room commands (live operator actions), and per-room policy delegation (MSC4284).
Configuration values shown here are documented in the example configuration with their defaults and full per-knob explanation. This page summarises each knob’s role in the moderation toolkit rather than restating the example file.
Federation server blocklists
The largest hammer. Three knobs in tuwunel-example.toml:
forbidden_remote_server_names: regex patterns matched against room IDs, room aliases, sender server names, sender users’ server names, inbound X-Matrix origins, and outbound federation handlers. Effectively a global ACL. Matching servers can neither send to nor receive from this homeserver, and local clients cannot join their rooms.allowed_remote_server_names_experimental: an allow-list counterpart. When non-empty, anything not matching is denied.forbidden_remote_server_namesis still applied after, so a wide allow (.*\\.example\\.com) plus a narrow deny (bad\\.example\\.com) is a valid composition.forbidden_remote_room_directory_server_names: limits the broader knob to outbound room-directory queries, useful when the goal is to keep users from discovering rooms on a server without cutting all federation with it.
These checks are inexpensive and apply on every relevant code path. Use a regex denylist when a server has crossed a hard line; use the directory-only variant for cases that warrant friction without isolation.
Admin commands for moderation
The admin room (joined automatically on first registration unless
create_admin_room = false) accepts the following moderation-relevant
commands. Run any command with --help for argument detail.
Rooms
!admin rooms moderation ban-room <room>: bans a room, evicts every local user including admins, removes local aliases, unpublishes from the directory, and disables federation with the room.!admin rooms moderation ban-list-of-rooms: bulk variant; takes a code block of room IDs or aliases.!admin rooms moderation unban-room <room>: reverses a ban and re-enables federation.!admin rooms moderation list-banned-rooms: lists every banned room.!admin rooms delete <room>: harder than ban; removes the room from the database after evicting users.
Federation
!admin federation disable-room <room>: blocks new inbound PDUs for one room without banning it. Useful for stalling a runaway room while investigating.!admin federation enable-room <room>: re-enables inbound handling.!admin federation incoming-federation: lists rooms with active inbound PDU handlers.!admin federation fetch-support-well-known <server>: fetches a remote server’s.well-known/matrix/supportrecord (administrator and security contacts), letting you raise abuse reports out-of-band before resorting to blocks.
Users
!admin users deactivate <user>: deactivates a local account; by default also leaves all rooms.!admin users deactivate-all: bulk variant accepting a code block of usernames.!admin users reject-invites <user>: rejects all pending invites, with an optional reason. Useful when an account has been targeted by an invite flood.!admin users redact-event <event_id>: forcibly redacts an event from a local sender even when the user is offline or unwilling.!admin users force-demote <user> <room>: drops a user’s power level to the room default when permissions allow.!admin users delete-room-tag/put-room-tag: room-tag housekeeping; them.server_noticetag pinned to the admin room is the typical use.
Media
See Multimedia and Storage > Management for the full admin command reference. Tools relevant to moderation:
!admin media delete --mxc <mxc_uri>: single-file removal.!admin media delete-by-event --event-id <event_id>: removes every MXC URI referenced by an event.!admin media delete-list: bulk removal from a code block of MXC URIs.!admin media delete-range <duration> --older-than|--newer-than: time-range delete; remote-only by default, local with the--yes-i-want-to-delete-local-mediaconfirmation.!admin media delete-all-from-user <username>: removes every upload by a local user.!admin media delete-all-from-server <server>: drops every cached copy of remote media from the named server.
Media policy
prevent_media_downloads_from: regex denylist for remote media downloads. Narrower thanforbidden_remote_server_names; the server can still federate, just no media flows in.media_storage_provider(under[media.storage]): when files live in an external provider, deletion semantics still apply through the same admin commands.
Identifier blocklists
Applied at registration, alias creation, and at startup as warnings against existing entries.
forbidden_alias_names: regex patterns matched against newly created room aliases and custom room IDs. Existing aliases that match are warned about on startup but not removed.forbidden_usernames: same shape, applied to local username availability checks and registration.
Invite gating
block_non_admin_invites: whentrue, only server admins can send room invites (local or remote) and only admins can receive remote invites. The cheapest mitigation for an invite-spam wave on a small or invite-only server.
Auto-deactivation triggers
auto_deactivate_banned_room_attempts: whentrue, any local user who attempts to join a banned room, an alias matchingforbidden_alias_names, or an alias / room ID containing aforbidden_remote_server_namesmatch, is fully deactivated and made to leave every room. Off by default because rooms are sometimes banned for non-moderation reasons.
Per-room policy delegation (MSC4284)
MSC4284 lets a room’s moderators delegate event signing to a third-party
policy server whose ed25519 signature must be present on every non-policy
event in the room. The signature folds into event.signatures and federates
transitively. Tuwunel implements outbound /sign on local sends, inbound
verification on federated receives, fetch-on-missing for inbound events
without a signature, and refusal/backoff caching to avoid hammering a server
that is rate-limiting or has refused.
Two configuration knobs:
enable_policy_servers: master switch (defaultfalse). Whenfalse, Tuwunel ignores policy state entirely. Whentrue, the gate engages only in rooms that carry a validm.room.policystate event.policy_server_request_timeout: seconds (default5) for both outbound/signand inbound signature-fetch requests.
Operator-relevant implications when enabling:
- Per-room opt-in. The global flag only allows the gate to engage; the
room’s own
m.room.policystate event is what activates it. Rooms without the state event are unaffected. - Latency cost. Every outbound send in a policy-room round-trips to the policy server before federating. The default 5-second cap prevents a single misbehaving policy server from stalling sends indefinitely.
- Fail-open on transport failure. Network errors and timeouts are logged and the event is sent or accepted unsigned, on the assumption that the next homeserver in the room will pick up the gap.
- Fail-closed on explicit refusal. A policy server returning
M_FORBIDDEN(or, on the unstable variant,200 OKwith no signature for the configuredvia) causes outbound sends to fail withM_FORBIDDEN, and inbound events to soft-fail. - Privacy in encrypted rooms. The PDU is forwarded to the policy server for signing. Ciphertext is opaque, but metadata (sender, timestamp, room, event type) is not. Encrypted-room policy delegation is the room’s call; Tuwunel does not block it.
- Refusal and rate-limit caching. Per-event refusals and per-policy-server
M_LIMIT_EXCEEDEDbackoffs are persisted, so repeated arrivals of the same event do not re-hit a refusing or throttled server.
For room version compatibility, MSC4416 (the room-version-13 successor that makes a missing or invalid policy signature an auth-rule rejection rather than a soft fail) is not yet active in Tuwunel and depends on upstream typing work. Until v13 ships, “leave it off” is safe; once v13 rooms become common, leaving it off in such a room means accepting events that the rest of the federation will reject.
URL previews and outbound network policy
URL preview generation is a frequent attack surface (SSRF, exfiltration via forced fetches). Tuwunel exposes both a domain policy layer and an IP egress layer.
Domain policy:
url_preview_domain_contains_allowlist,url_preview_url_contains_allowlist: permissive contains-match allowlists. The contains-match is intentionally loose; treat as “anything mentioning this string passes”.url_preview_domain_explicit_allowlist: strict equality match on the host.url_preview_domain_explicit_denylist: strict equality match, evaluated before the allowlist.url_preview_check_root_domain: whentrue, applies the contains and explicit allowlists against the root domain, so an allow onwikipedia.orgalso letsen.m.wikipedia.orgthrough.url_preview_max_spider_size(bytes; SI/IEC suffix accepted): caps the spider response body.url_preview_bound_interface: pins outbound preview requests to a specific interface or source IP. Linux/Android/Fuchsia accept interface names (eth0); other platforms accept addresses.
IP egress:
ip_range_denylist: list of IPv4/IPv6 CIDR ranges Tuwunel will not send outbound requests to. Defaults to RFC1918, loopback, multicast, link-local, and the documentation/testnet ranges. This is application-layer enforcement and not a substitute for a host firewall, but it closes the obvious SSRF vectors out of the box. Set to[]only if a firewall is enforcing the same constraints upstream.
Redaction retention and forensics
Moderation actions often hinge on what an event said before redaction.
save_unredacted_events(defaulttrue): keeps the pre-redaction PDU in storage so admins can recover content for incident review.redaction_retention_seconds(default 60 days): how long unredacted copies live before being dropped. Lower this for tighter privacy posture; raise it when investigations span longer horizons.allow_room_admins_to_request_unredacted_events(defaulttrue): lets a user withredactpower level retrieve unredacted copies via MSC2815. Server admins can request regardless.disable_local_redactions: blocks local users from sending redactions (server admins exempt). Useful only in archival or read-only deployments; most operators leave this off.
The !admin debug get-retained-pdu command surfaces a retained event from
the admin room.
Responding to a spam incident
When a server sends spam media to your users, the typical response is:
1. Identify the source server.
Check the MXC URIs in the reported messages. The server name is the
authority component: mxc://<server_name>/<media_id>. The sender server
on the offending event is the canonical source even when media is hosted
elsewhere.
2. Delete cached copies of the spam media.
!admin media delete-all-from-server badserver.tld
3. Block future media downloads from that server.
Add the server to prevent_media_downloads_from in your config and reload
or restart Tuwunel:
prevent_media_downloads_from = ["badserver\\.tld$"]
4. If the spam arrived within a known time window, use delete-range to
catch anything missed:
!admin media delete-range 2h --newer-than
5. If you have a list of specific MXC URIs (e.g. from a moderation tool
or a shared blocklist), use delete-list to remove them in bulk.
6. Consider server-level federation blocks via
forbidden_remote_server_names if the server is persistently abusive. This
blocks all federation traffic, not just media, and so is the right tool only
once the source has demonstrated it will not stop.
7. If the spam came as invite floods, set block_non_admin_invites = true
to halt all non-admin invite traffic during the incident, then run
!admin users reject-invites <user> for affected accounts. Re-enable invites
once the source server is blocked.
8. If a single room is the vector (for example, a public room being used
to flood a user’s timeline), !admin rooms moderation ban-room <room> evicts
your local users and severs federation with the room without affecting
other rooms on the same server.
9. For ongoing moderation against a class of senders, consider whether a per-room MSC4284 policy server is appropriate. This delegates per-event signing to a moderation service, with the trade-offs described in Per-room policy delegation above. This is a heavier setup than a one-time response and is worth it only when the room expects sustained policy enforcement.
Maintaining your Tuwunel setup
Moderation
Tuwunel has moderation through admin room commands. “binary commands” (medium priority) and an admin API (low priority) is planned. Some moderation-related config options are available in the example config such as “global ACLs” and blocking media requests to certain servers. See the example config for the moderation config options under the “Moderation / Privacy / Security” section.
Tuwunel has moderation admin commands for:
- managing room aliases (
!admin rooms alias) - managing room directory (
!admin rooms directory) - managing room banning/blocking and user removal (
!admin rooms moderation) - managing user accounts (
!admin users) - fetching
/.well-known/matrix/supportfrom servers (!admin federation) - blocking incoming federation for certain rooms (not the same as room banning)
(
!admin federation) - deleting media (see the media section)
Any commands with -list in them will require a codeblock in the message with
each object being newline delimited. An example of doing this is:
!admin rooms moderation ban-list-of-rooms
```
!roomid1:server.name
#badroomalias1:server.name
!roomid2:server.name
!roomid3:server.name
#badroomalias2:server.name
```
Database (RocksDB)
Generally there is very little you need to do. Compaction is ran automatically based on various defined thresholds tuned for Tuwunel to be high performance with the least I/O amplifcation or overhead. Manually running compaction is not recommended, or compaction via a timer, due to creating unnecessary I/O amplification. RocksDB is built with io_uring support via liburing for improved read performance.
RocksDB troubleshooting can be found in the RocksDB section of troubleshooting.
Compression
Some RocksDB settings can be adjusted such as the compression method chosen. See the RocksDB section in the example config.
btrfs users have reported that database compression does not need to be disabled
on Tuwunel as the filesystem already does not attempt to compress. This can be
validated by using filefrag -v on a .SST file in your database, and ensure
the physical_offset matches (no filesystem compression). It is very important
to ensure no additional filesystem compression takes place as this can render
unbuffered Direct IO inoperable, significantly slowing down read and write
performance. See https://btrfs.readthedocs.io/en/latest/Compression.html#compatibility
Important
Compression is done using the COW mechanism so it’s incompatible with nodatacow. Direct IO read works on compressed files but will fall back to buffered writes and leads to no compression even if force compression is set. Currently nodatasum and compression don’t work together.
ZFS
ZFS has several quirks that interact badly with RocksDB defaults. Apply both the Tuwunel config changes and the dataset properties below.
In tuwunel.toml:
rocksdb_direct_io = false. OpenZFS prior to 2.3 silently ignoredO_DIRECTand fell back to buffered. OpenZFS 2.3+ honorsO_DIRECTonly when requests are page-aligned and a multiple of the recordsize, which RocksDB cannot guarantee.rocksdb_allow_fallocate = false. OpenZFS does not implementfallocate(2)preallocation; onlyFALLOC_FL_PUNCH_HOLEandFALLOC_FL_ZERO_RANGEare supported.- Leave
rocksdb_optimize_for_spinning_disks = falseon NVMe or SSD pools, even when running on ZFS.
On the dataset hosting database_path:
| Property | Value | Reason |
|---|---|---|
recordsize | 128K (or 64K) | Match RocksDB’s working set. 16K causes severe write amplification on compaction. |
primarycache | metadata | Tuwunel’s block cache already serves data; ARC caching of data duplicates RAM. |
compression | off | RocksDB SSTs are already zstd-compressed by Tuwunel. |
atime | off | Avoid an FS write per read. |
logbias | throughput | Route ZIL through the normal txg path, which suits append-only WAL traffic. |
recordsize takes effect only on files written after the property is
changed. After adjusting it, dump the database (offline copy out, wipe the
dataset, copy back) so existing SSTs adopt the new recordsize. Without a
dump-and-reload, compaction will gradually rewrite into the new recordsize
over weeks; pre-existing files keep the old size in the meantime.
For sync write latency, in order of preference: a separate SLOG vdev, then
logbias=throughput, then sync=disabled (only if you accept that a host
crash may discard the WAL tail; Tuwunel recovers cleanly from this via
rocksdb_recovery_mode=1, the default).
Files in database
Do not touch any of the files in the database directory. This must be said due
to users being mislead by the .log files in the RocksDB directory, thinking
they’re server logs or database logs, however they are critical RocksDB files
related to WAL tracking.
The only safe files that can be deleted are the LOG files (all caps). These
are the real RocksDB telemetry/log files, however Tuwunel has already
configured to only store up to 3 RocksDB LOG files due to generally being
useless for average users unless troubleshooting something low-level. If you
would like to store nearly none at all, see the rocksdb_max_log_files
config option.
Online backups
Currently only RocksDB supports online backups. If you’d like to backup your
database online without any downtime, see the !admin server command for the
backup commands and the database_backup_path config options in the example
config.
Please note that the format of the database backup is not the exact same. This is unfortunately a bad design choice by Facebook as we are using the database backup engine API from RocksDB, however the data is still there and can still be joined together.
Restoring online backup
To restore a backup from an online RocksDB backup:
- shutdown Tuwunel
- create a new directory for merging together the data
- in the online backup created, copy all
.sstfiles in$DATABASE_BACKUP_PATH/shared_checksumto your new directory - trim all the strings so instead of
######_sxxxxxxxxx.sst, it reads######.sst. A way of doing this with sed and bash isfor file in *.sst; do mv "$file" "$(echo "$file" | sed 's/_s.*/.sst/')"; done - copy all the files in
$DATABASE_BACKUP_PATH/1(or the latest backup number if you have multiple) to your new directory - set your
database_pathconfig option to your new directory, or replace your old one with the new one you crafted - start up Tuwunel again and it should open as normal
Offline backups
If you’d like to do an offline backup, shutdown Tuwunel and copy your
database_path directory elsewhere. This can be restored with no modifications
needed.
Backing up media is also just copying the media/ directory from your database
directory.
Media
Media still needs various work, however Tuwunel implements media deletion via:
- MXC URI or Event ID (unencrypted and attempts to find the MXC URI in the event)
- Delete list of MXC URIs
- Delete remote media in the past
Nseconds/minutes via filesystem metadata on the file created time (btime) or file modified time (mtime)
See the !admin media command for further information. All media in Tuwunel
is stored at $DATABASE_DIR/media. This will be configurable soon.
If you are finding yourself needing extensive granular control over media, we recommend looking into Matrix Media Repo. Tuwunel intends to implement various utilities for media, but MMR is dedicated to extensive media management.
Built-in S3 support is also planned, but for now using a “S3 filesystem” on
media/ works. Tuwunel also sends a Cache-Control header of 1 year and
immutable for all media requests (download and thumbnail) to reduce unnecessary
media requests from browsers, reduce bandwidth usage, and reduce load.
Troubleshooting Tuwunel
Important
If you intend on asking for support and you are using Docker, PLEASE triple validate your issues are NOT because you have a misconfiguration in your Docker setup. We must remain focused on supporting Tuwunel issues and cannot budget our time for generic Docker support. Compose file issues or Dockerhub image issues are okay if they are something we can fix.
Tuwunel and Matrix issues
Lost access to admin room
You can reinvite yourself to the admin room through the following methods:
- Use the
--execute "users make_user_admin <username>"Tuwunel binary argument once to invite yourself to the admin room on startup - Use the Tuwunel console/CLI to run the
users make_user_admincommand - Or specify the
emergency_passwordconfig option to allow you to temporarily log into the server account (@conduit) from a web client
General potential issues
Potential DNS issues when using Docker
Docker has issues with its default DNS setup that may cause DNS to not be properly functional when running Tuwunel, resulting in federation issues. The symptoms of this have shown in excessively long room joins (30+ minutes) from very long DNS timeouts, log entries of “mismatching responding nameservers”, and/or partial or non-functional inbound/outbound federation.
This is not a Tuwunel issue, and is purely a Docker issue. It is not sustainable for heavy DNS activity which is normal for Matrix federation. The workarounds for this are:
- Use DNS over TCP via the config option
query_over_tcp_only = true - Don’t use Docker’s default DNS setup and instead allow the container to use
and communicate with your host’s DNS servers (host’s
/etc/resolv.conf)
DNS No connections available error message
If you receive spurious amounts of error logs saying “DNS No connections
available”, this is due to your DNS server (servers from /etc/resolv.conf)
being overloaded and unable to handle typical Matrix federation volume. Some
users have reported that the upstream servers are rate-limiting them as well
when they get this error (e.g. popular upstreams like Google DNS).
Matrix federation is extremely heavy and sends wild amounts of DNS requests. Unfortunately this is by design and has only gotten worse with more server/destination resolution steps. Synapse also expects a very perfect DNS setup.
There are some ways you can reduce the amount of DNS queries, but ultimately the best solution/fix is selfhosting a high quality caching DNS server like Unbound without any upstream resolvers, and without DNSSEC validation enabled.
DNSSEC validation is highly recommended to be disabled due to DNSSEC being very computationally expensive, and is extremely susceptible to denial of service, especially on Matrix. Many servers also strangely have broken DNSSEC setups and will result in non-functional federation.
Tuwunel cannot provide a “works-for-everyone” Unbound DNS setup guide, but
the official Unbound tuning guide and the Unbound Arch Linux wiki page
may be of interest. Disabling DNSSEC on Unbound is commenting out trust-anchors
config options and removing the validator module.
Avoid using systemd-resolved as it does not perform very well under
high load, and we have identified its DNS caching to not be very effective.
dnsmasq can possibly work, but it does not support TCP fallback which can be
problematic when receiving large DNS responses such as from large SRV records.
If you still want to use dnsmasq, make sure you disable dns_tcp_fallback
in Tuwunel config.
Raising dns_cache_entries in Tuwunel config from the default can also assist
in DNS caching, but a full-fledged external caching resolver is better and more
reliable.
If you don’t have IPv6 connectivity, changing ip_lookup_strategy to match
your setup can help reduce unnecessary AAAA queries
(1 - Ipv4Only (Only query for A records, no AAAA/IPv6)).
If your DNS server supports it, some users have reported enabling
query_over_tcp_only to force only TCP querying by default has improved DNS
reliability at a slight performance cost due to TCP overhead.
RocksDB / database issues
Database corruption
There are many causes and varieties of database corruption. There are several methods for mitigation, each with outcomes ranging from a recovered state down to a savage state. This guide has been simplified into a set of universal steps which everyone can follow from the top until they have recovered or reach the end. The details and implications will be explained within each step.
Tip
All command-line
-Ooptions can be expressed as environment variables or in the config file based on your deployment’s requirements. Note that--maintenanceis equivalent to configuringstartup_netburst = falseandlistening = false.
Important
Always create a backup of the database before running any operation. This is critical for steps 3 and above.
0. Start the server with the following options:
tuwunel --maintenance -O rocksdb_recovery_mode=0
This is actually a “control” and not a method of recovery. If the server starts you either do not have corruption or have deep corruption indicated by very specific errors from rocksdb citing corruption during runtime. If you are certain there is deep corruption skip to step 4, otherwise you are finished without any modifications.
1. Start the server in Tolerate-Corrupted-Tail-Records mode:
tuwunel --maintenance -O rocksdb_recovery_mode=1
The most common corruption scenario is from a loss of power to the hardware (not an application crash, though it is still possible). This is remediated by dropping the most recently written record. It is highly unlikely there will be any impact on the application from this loss. In the best-case the same data is often re-requested over the federation or replaced by a client. In the worst-case clients may need to clear-cache & reload to guarantee correctness. If the server starts you are finished.
2. Start the server in Point-In-Time mode:
tuwunel --maintenance -O rocksdb_recovery_mode=2
Similar to the corruption scenario above but for more severe cases. The most recent records are discarded back to the point where there is no corruption. It is highly unlikely there will be any impact on the application from this loss, but it is more likely than above that clients may need to clear-cache & reload to correctly resynchronize with the server.
3. Start the server in Skip-Any-Corrupted-Record mode:
Warning
Salvage mode potentially impacting the application’s ability to function. We cannot provide support for users who have entered this mode.
tuwunel --maintenance -O rocksdb_recovery_mode=3
Similar to the prior corruption scenarios but for the most severe cases. The database will be inconsistent. It is theoretically possible for the server to continue functioning without notable issue in the best case, but it is completely uncertain what the effect of this operation will be. If the server starts you should immediately export your messages, encryption keys, etc, in a salvage effort and prepare to reinstall.
4. Start the server in repair mode.
Warning
Salvage mode potentially impacting the application’s ability to function. We cannot provide support for users who have entered this mode.
Caution
Always create a backup of the database before entering this mode. The repair is not configurable and not interactive. It may automatically remove more data than anticipated, preventing further salvage efforts.
tuwunel --maintenance -O rocksdb_repair=true
For corruption affecting the bulk database tables not covered by any journal. This will leave the database in an inconsistent and unpredictable state. It is theoretically possible to continue operating the server depending on which records were dropped, such as some historical records which are no longer essential. Nevertheless the impact of this operation is impossible to assess and a successful recovery should be used to salvage data prior to reinstall.
Once finished, restart the server without rocksdb_repair. If no errors
persist, restart the server again without maintenance mode.
5. Utilize an external repair tool.
Warning
Salvage mode potentially impacting the application’s ability to function. We cannot provide support for users who have entered this mode.
git clone https://github.com/facebook/rocksdb
cd rocksdb
make -j$(nproc) ldb
./ldb repair --db=/var/lib/tuwunel/ 2>./repair-log.txt
For situations when the repair mode in step 4 failed or produced unexpected results.
Debugging
Note that users should not really be debugging things. If you find yourself
debugging and find the issue, please let us know and/or how we can fix it.
Various debug commands can be found in !admin debug.
Debug/Trace log level
Tuwunel builds without debug or trace log levels at compile time by default
for substantial performance gains in CPU usage and improved compile times. If
you need to access debug/trace log levels, you will need to build without the
release_max_log_level feature or use our provided release-logging binaries
and images.
Changing log level dynamically
Tuwunel supports changing the tracing log environment filter on-the-fly using
the admin command !admin debug change-log-level <log env filter>. This accepts
a string without quotes the same format as the log config option.
Example: !admin debug change-log-level debug
This can also accept complex filters such as:
!admin debug change-log-level info,conduit_service[{dest="example.com"}]=trace,ruma_state_res=trace
!admin debug change-log-level info,conduit_service[{dest="example.com"}]=trace,conduit_service[send{dest="example.org"}]=trace
And to reset the log level to the one that was set at startup / last config
load, simply pass the --reset flag.
!admin debug change-log-level --reset
Pinging servers
Tuwunel can ping other servers using !admin debug ping <server>. This takes
a server name and goes through the server discovery process and queries
/_matrix/federation/v1/version. Errors are outputted.
While it does measure the latency of the request, it is not indicative of server performance on either side as that endpoint is completely unauthenticated and simply fetches a string on a static JSON endpoint. It is very low cost both bandwidth and computationally.
Allocator memory stats
When using jemalloc with jemallocator’s stats feature (--enable-stats), you
can see Tuwunel’s high-level allocator stats by using
!admin server memory-usage at the bottom.
If you are a developer, you can also view the raw jemalloc statistics with
!admin debug memory-stats. Please note that this output is extremely large
which may only be visible in the Tuwunel console CLI due to PDU size limits,
and is not easy for non-developers to understand.
Matrix RTC test tool
testmatrix is a command line tool for testing various aspects of a matrix server and guiding debugging. Details for troubleshooting Matrix RTC can be found in the Matrix RTC chapter on troubleshooting.
Development
Information about developing the project. If you plan on contributing, see the contributor’s guide. If you are only interested in using it, you can safely ignore this page.
Rust Documentation
Tuwunel’s rustdocs are hosted within this book under the /docs
directory. Developers may build the same documentation locally using cargo doc.
Tuwunel project layout
Tuwunel uses a collection of sub-crates, packages, or workspace members
that indicate what each general area of code is for. All of the workspace
members are under src/. The workspace definition is at the top level
Cargo.toml. See the Rust documentation on Workspaces for
general questions and information on Cargo workspaces.
Tuwunel’s crates form a directed acyclic-graph without circular dependencies. Listed here from the top are the most abstract down to the most dependent at the bottom. Crates only have visibility into other crates listed above them; they cannot see structs or call functions in any crate listed below them.
-
tuwunel_macrosare Tuwunel Rust macros like general helper macros, logging and error handling macros, and syn and procedural macros. -
tuwunel_coreis core Tuwunel functionality like config loading, error definitions, global utilities, logging infrastructure, etc. -
tuwunel_databaseis RocksDB encapsulation, interface wrappers, configurations, and our opinionated asynchronous database frontend. -
tuwunel_serviceis stateful runtime functionality at the heart of the application. This crate is divided into “services” each with “workers” and queues and all of the moving parts that attend to the tasks of sending messages and notifications, etc. Each service attempts to encapsulate any database tables it requires for its persistent state. Services call other services and they do not form an acyclic graph, for now. -
tuwunel_apiis the stateless runtime functionality which implements the Matrix C-S and S-S API’s in a broad set of http handlers. These handlers call various services to query or update their state as necessary. They do not interface with raw data or database functions except through a service. -
tuwunel_adminis a module that implements the admin room as a broad set of command API handlers. Similar totuwunel_apithese handlers also interface with various services as necessary. Currently the admin crate does not call intotuwunel_apias a dependency, but this is not intentional and subject to change. -
tuwunel_routeris the webserver and request handling bits, using axum, tower, tower-http, hyper, etc, and the server state to drive thetuwunel_apihandlers. -
mainis the binary executable. This is where themain()function lives, tokio worker and async initialisation, Sentry initialisation, clap init, and signal handling. If you are adding new Rust features, they must go here. This crate is also capable of compiling as a library for integration testing and embedding.
Notes
It is highly unlikely you will ever need to add a new workspace member, instead look to create a new Service to implement distinct or unique functionality. If you truly find yourself needing another crate, we recommend reaching out to us in the Matrix room for discussions about it beforehand.
The primary inspiration for this design was apart of hot reloadable development,
to support “Tuwunel as a library” where specific parts can simply be swapped out.
There is evidence Conduit wanted to go this route too as axum is technically an
optional feature in Conduit, and can be compiled without the binary or axum library
for handling inbound web requests; but it was never completed or worked.
Adding compile-time features
If you’d like to add a compile-time feature, you must first define it in
the main workspace crate located in src/main/Cargo.toml. The feature must
enable a feature in the other workspace crate(s) you intend to use it in. Then
the said workspace crate(s) must define the feature there in its Cargo.toml.
So, if this is adding a feature to the API such as woof, you define the feature
in the api crate’s Cargo.toml as woof = []. The feature definition in main’s
Cargo.toml will be woof = ["tuwunel-api/woof"].
The rationale for this is due to Rust / Cargo not supporting “workspace level features”, we must make a choice of; either scattering features all over the workspace crates, making it difficult for anyone to add or remove default features; or define all the features in one central workspace crate that propagate down/up to the other workspace crates. It is a Cargo pitfall, and we’d like to see better developer UX in Rust’s Workspaces.
Additionally, the definition of one single place makes “feature collection” in our
Nix flake a million times easier instead of collecting and deduping them all from
searching in all the workspace crates’ Cargo.tomls. Though we wouldn’t need to
do this if Rust supported workspace-level features to begin with.
List of forked dependencies
During Tuwunel development, we have had to fork some dependencies to support our use-cases in some areas. This ranges from things said upstream project won’t accept for any reason, faster-paced development (unresponsive or slow upstream), Tuwunel-specific usecases, or lack of time to upstream some things.
- ruma/ruma: https://github.com/matrix-construct/ruma - various performance improvements, more features, faster-paced development, better client/server interop hacks upstream won’t accept, etc
- facebook/rocksdb: https://github.com/matrix-construct/rocksdb - liburing build fixes and GCC debug build fix
- tikv/jemallocator: https://github.com/matrix-construct/jemallocator - musl builds seem to be broken on upstream, fixes some broken/suspicious code in places, additional safety measures, and support redzones for Valgrind
- zyansheep/rustyline-async:
https://github.com/matrix-construct/rustyline-async - tab completion callback and
CTRL+\signal quit event for Tuwunel console CLI - rust-rocksdb/rust-rocksdb:
https://github.com/matrix-construct/rust-rocksdb-zaidoon1 -
@zaidoon1’s fork has quicker updates, more up to date dependencies, etc. Our fork fixes musl build issues, removes unnecessarygtestinclude, and uses our RocksDB and jemallocator forks. - tokio-rs/tracing: https://github.com/matrix-construct/tracing - Implements
CloneforEnvFilterto support dynamically changing tracing envfilter’s alongside other logging/metrics things
Debugging with tokio-console
tokio-console can be a useful tool for debugging and profiling. To make a
tokio-console-enabled build of Tuwunel, enable the tokio_console feature,
disable the default release_max_log_level feature, and set the --cfg tokio_unstable flag to enable experimental tokio APIs. A build might look like
this:
RUSTFLAGS="--cfg tokio_unstable" cargo +nightly build \
--release \
--no-default-features \
--features=systemd,element_hacks,gzip_compression,brotli_compression,zstd_compression,tokio_console
You will also need to enable the tokio_console config option in Tuwunel when
starting it. This was due to tokio-console causing gradual memory leak/usage
if left enabled.
Contributing guide
If you would like to work on an issue that is not assigned, preferably ask in the Matrix room first at #tuwunel:grin.hu, and comment on it.
Inclusivity and Diversity
All MUST code and write with inclusivity and diversity in mind. See the following page by Google on writing inclusive code and documentation.
This EXPLICITLY forbids usage of terms like “blacklist”/“whitelist” and “master”/“slave”, forbids gender-specific words and phrases, forbids ableist language like “sanity-check”, “cripple”, or “insane”, and forbids culture-specific language (e.g. US-only holidays or cultures).
No exceptions are allowed. Dependencies that may use these terms are allowed but do not replicate the name in your functions or variables.
In addition to language, write and code with the user experience in mind. This is software that intends to be used by everyone, so make it easy and comfortable for everyone to use. 🏳️⚧️
Linting and Formatting
It is mandatory all your changes satisfy the lints (clippy, rustc, rustdoc, etc)
and your code is formatted via the nightly cargo fmt. A lot of the
rustfmt.toml features depend on nightly toolchain. It would be ideal if they
weren’t nightly-exclusive features, but they currently still are. CI’s rustfmt
uses nightly.
If you need to allow a lint, please make sure it’s either obvious as to why (e.g. clippy saying redundant clone but it’s actually required) or it has a comment saying why. Do not write inefficient code for the sake of satisfying lints. If a lint is wrong and provides a more inefficient solution or suggestion, allow the lint and mention that in a comment.
Variable, comment, function, etc standards
Rust’s default style and standards with regards to function names, variable names, comments, etc applies here.
Software testing
Continuous integration runs Complement protocol compliance tests
against Tuwunel. The results are compared against a stored baseline via
git diff — both new failures and new passes are flagged. If your changes
affect compliance, note it in your pull request and review the result diff
uploaded as an artifact.
See Complement Testing for details on how the test harness works and how to run Complement locally against a debug or release build.
Writing documentation
Tuwunel’s website uses mdbook containing rustdoc
which are deployed via CI pipeline using GitHub Pages. All documentation is
in the docs/ directory at the top level. The compiled mdbook website is
also uploaded as an artifact.
-
To build the book locally run
mdbook build -d <outdir> .in the project root. -
To build the book using local stages of the CI pipeline run
docker/bake.sh book; the produced docker image will contain it. -
To build the book using Nix, run:
bin/nix-build-and-cache just .#book
Rust API documentation (rustdoc) is generated from the sourcecode contained
in src/ and deployed via CI to a directory within GitHub Pages adjacent to
the book. In other contexts mdbook and rustdoc are independent.
-
To build the API documents locally run
cargo docand browse totarget/doc/tuwunel/. -
To build the API documents using local stages of the CI pipeline run
docker/bake.sh docs; the produced docker image will contain results in/usr/src/tuwunel/target/x86_64-unknown-linux-gnu/doc(and one can extrapolate for other platforms).
Creating pull requests
Please try to keep contributions to the GitHub. While the mirrors of Tuwunel allow for pull/merge requests, there is no guarantee I will see them in a timely manner. Additionally, please mark WIP or unfinished or incomplete PRs as drafts. This prevents me from having to ping once in a while to double check the status of it, especially when the CI completed successfully and everything so it looks done.
If you open a pull request on one of the mirrors, it is your responsibility to
inform me about its existence. In the future I may try to solve this with more
repo bots in the Tuwunel Matrix room. There is no mailing list or email-patch
support on the sr.ht mirror, but if you’d like to email me a git patch you can
do so at jasonzemos@gmail.com.
Direct all PRs/MRs to the main branch.
By sending a pull request or patch, you are agreeing that your changes are allowed to be licenced under the Apache-2.0 licence and all of your conduct is in line with the Contributor’s Covenant, and Tuwunel’s Code of Conduct.
Contribution by users who violate either of these code of conducts will not have their contributions accepted. This includes users who have been banned from Tuwunel Matrix rooms for Code of Conduct violations.
Stale Branch Policy
This section applies to Matrix-Construct members and Tuwunel maintainers
Branches on the matrix-construct/tuwunel repository are centrally maintained. They may be rebased without your consent. Trivial conflicts may be resolved by another maintainer. Please resolve more difficult conflicts as soon as possible. Stale branches may be deleted in some cases; personal repositories are advised for avoiding any such complications.
Protocol Implementation
Specification Compliance
Todo
MSC Implementation Status
The status table lists every memo Tuwunel knows about,
Complement Testing
The Complement results page summarizes runs of the Complement homeserver acceptance suite.
Tuwunel MSC Implementation Status
Columns
- Inv: inventory status (matrix-spec-proposals state). One of
merged,open,closed,unknown. - Status: ✅
yes/ 🟨partial/ ❌no/ ⬛n/a, followed by a confidence glyph (● high / ◐ med / ○ low / · unknown) that reflects confidence in the assessment, not in the implementation. - Correct/Impl: two absolute percentages of the total proposal,
e.g.
70/80. Correct is the share of the proposal’s requirements Tuwunel adheres to correctly; Impl is the share that has any code path attempting adherence. By definition Correct <= Impl. Either may be?. Proposals are loosely normative, so this is NOT just MUST/SHOULD: every requirement-shaped statement counts (“the server returns X”, “this field is added to Y”, etc.).
Counts
- ✅
yes: 216 - 🟨
partial: 59 - ❌
no: 457 - ⬛
n/a: 292
Status by inventory bucket
| Inv | yes | partial | no | n/a | total |
|---|---|---|---|---|---|
| merged | 150 | 30 | 12 | 64 | 256 |
| open | 58 | 28 | 406 | 176 | 668 |
| closed | 8 | 1 | 39 | 52 | 100 |
Merged
Sorted by MSC number, highest first. Out-of-scope rows are listed in the Out of scope section.
| MSC | Status | Correct/Impl | Title | Note |
|---|---|---|---|---|
| MSC4380 | 🟨 ● | 70/70 | Invite blocking | phase A landed (invite-creating endpoints gated, M_INVITE_BLOCKED 403); phase… |
| MSC4376 | ✅ ● | 100/100 | Remove /v1/send_join and /v1/send_leave | v1 send_join and v1 send_leave routes are not registered |
| MSC4341 | ❌ ● | 0/0 | Support for RFC 8628 Device Authorization Grant | OAuth Device Authorization Grant (RFC 8628) not advertised |
| MSC4335 | ❌ ● | 0/0 | M_USER_LIMIT_EXCEEDED error code | M_USER_LIMIT_EXCEEDED error code not used |
| MSC4326 | ✅ ● | 100/100 | Device masquerading for appservices | appservice query device_id asserted; M_UNKNOWN_DEVICE-equivalent on missing |
| MSC4323 | ✅ ● | 100/100 | User suspension & locking endpoints | src/api/client/admin.rs four routes at stable v1 paths; m.account_moderation … |
| MSC4312 | ✅ ● | 90/100 | Resetting cross-signing keys in the OAuth world | cross-signing reset issues m.oauth flow with account-management URL |
| MSC4311 | 🟨 ◐ | 0/? | Ensuring the create event is available on invites | complement: 0p/1f |
| MSC4307 | ✅ ● | 100/100 | Validate that auth_events are in the correct room | auth_event room_id mismatch rejected |
| MSC4304 | ✅ ● | 90/100 | Room Version 12 | V12 supported as stable; default is V11 |
| MSC4297 | ✅ ● | 100/100 | State Resolution v2.1 | src/service/rooms/state_res/resolve.rs:257 conflicted state subgraph; tests pass |
| MSC4291 | 🟨 ● | 80/90 | Room IDs as hashes of the create event | hydra.11 room id format and auth rules in event_auth, pdu format checks |
| MSC4289 | ✅ ● | 100/100 | Explicitly privilege room creators | src/service/tests/state_res/fixtures/MSC4297-problem-A/pdus-hydra.json:5; com… |
| MSC4284 | ✅ ● | 90/90 | Policy Servers | outbound /sign, inbound verify, fetch-on-missing, refusal/backoff cache; v13 … |
| MSC4277 | 🟨 ◐ | 30/40 | Harmonizing the reporting endpoints | event and room report endpoints exist; user report endpoint absent |
| MSC4267 | ✅ ● | 100/100 | Automatically forgetting rooms on leave | forget_forced_upon_leave config honored on Leave or Ban; capability advertised |
| MSC4260 | ✅ ● | 100/100 | Reporting users (Client-Server API) | src/api/client/report.rs:63; admin notification, 404 M_NOT_FOUND on unknown u… |
| MSC4254 | ✅ ● | 100/100 | Usage of [RFC7009] Token Revocation for Matrix client logout | src/api/oidc/revoke.rs:37; RFC7009 form-urlencoded; revokes both tokens; 200 … |
| MSC4239 | ✅ ● | 100/100 | Room version 11 as the default room version | default_default_room_version = V11 |
| MSC4230 | ✅ ● | 100/100 | ‘Animated’ flag for images | event-only; passthrough; merged in spec |
| MSC4225 | ✅ ● | 100/100 | Specification of an order in which one-time-keys should be issued | OTKs issued in upload order via count_be prefix; src/service/users/keys.rs:99 |
| MSC4222 | ✅ ● | 100/100 | Adding state_after to /sync | src/api/client/sync/v3.rs; use_state_after wired through joined+left rooms; s… |
| MSC4213 | ✅ ● | 90/90 | Remove server_name parameter | join/knock use via; server_name still accepted via Ruma fallback |
| MSC4210 | ✅ ● | 100/100 | Remove legacy mentions | deprecated mention push rules removed at /pushrules read time |
| MSC4191 | 🟨 ◐ | 50/80 | Account management for OAuth 2.0 API | metadata wired but action names diverge from MSC |
| MSC4190 | ✅ ● | 90/90 | Device management for application services | appservices with device_management can create, update, delete devices without… |
| MSC4189 | ✅ ◐ | 80/100 | Allowing guests to access uploaded media | guest tokens accepted on authenticated media routes |
| MSC4180 | ✅ ● | 100/100 | Add a stable flag to MSC3916 | stable feature flag for MSC3916 advertised |
| MSC4175 | ✅ ● | 100/100 | Profile field for user time zone | timezone PUT/DELETE/GET routes; m.tz aliased in profile and over federation |
| MSC4170 | ✅ ◐ | 100/100 | 403 error responses for profile APIs | profile lookup unrestricted; MUST minimum satisfied |
| MSC4169 | ✅ ● | 100/100 | Backwards-compatible redaction sending using /send | src/api/client/send.rs:42; lifts content.redacts into PduBuilder.redacts; adv… |
| MSC4163 | ✅ ● | 100/100 | Make ACLs apply to EDUs | ACLs applied on receipt and typing EDUs |
| MSC4156 | ✅ ● | 100/100 | Migrate server_name to via | via parameter handled via Ruma |
| MSC4151 | ✅ ● | 100/100 | Reporting rooms (Client-Server API) | POST /rooms/{roomId}/report implemented and routed |
| MSC4138 | ✅ ● | 100/100 | Update allowed HTTP methods in CORS responses | CORS METHODS list includes HEAD and PATCH; excludes CONNECT/TRACE |
| MSC4133 | 🟨 ● | 70/80 | Extending User Profile API with Custom Key:Value Pairs | GET/PUT/DELETE profile field endpoints routed at unstable prefix |
| MSC4126 | ✅ ● | 100/100 | Deprecation of query string auth | deprecation of query string auth; server still accepts both |
| MSC4115 | ✅ ● | 100/100 | membership metadata on events | src/core/matrix/pdu/unsigned.rs add_membership; src/service/rooms/state_acces… |
| MSC4041 | ✅ ◐ | 90/90 | Use http header Retry-After to enable library-assisted retry handling | Ruma error type emits Retry-After header for LimitExceeded responses. |
| MSC4040 | ✅ ● | 100/100 | Update SRV service name to IANA registration | Tuwunel queries _matrix-fed first then falls back to _matrix. |
| MSC4026 | ✅ ◐ | 80/90 | Allow /versions to optionally accept authentication | versions endpoint accepts optional auth via Ruma |
| MSC4025 | 🟨 ● | 50/50 | Local user erasure requests | phase A landed (account-data wipe); phase B (per-event visibility gate) deferred |
| MSC4010 | ✅ ● | 100/100 | Push rules and account data | m.push_rules and m.fully_read rejected on /account_data |
| MSC4009 | ✅ ● | 100/100 | Expanding the Matrix ID grammar to enable E.164 IDs | E.164 + character allowed via Ruma localpart validation |
| MSC3989 | ✅ ● | 100/100 | Redact origin property on events | V11 redaction drops origin via Ruma RedactionRules |
| MSC3987 | ✅ ● | 90/90 | Push actions clean-up | unknown push actions ignored as no-ops |
| MSC3981 | ✅ ● | 100/100 | /relations recursion | /relations recurse parameter implemented with depth 3 |
| MSC3980 | ❌ ● | 0/0 | Dotted Field Consistency | blocked on a missing prerequisite: Tu does not implement event_fields filteri… |
| MSC3970 | ✅ ● | 90/100 | Scope transaction IDs to devices | transaction IDs scoped per (user, device, txn_id) |
| MSC3967 | ✅ ● | 100/100 | Do not require UIA when first uploading cross signing keys | keys/device_signing/upload skips UIA when user has no existing cross-signing … |
| MSC3966 | ✅ ● | 100/100 | event_property_contains push rule condition | event_property_contains supported via Ruma push conditions |
| MSC3958 | ✅ ● | 100/100 | Suppress notifications from message edits | SuppressEdits push rule provided via Ruma server_default ruleset |
| MSC3952 | ✅ ◐ | 80/90 | Intentional Mentions | Intentional mentions push rules ride on Ruma server_default; flag advertised. |
| MSC3943 | ✅ ● | 100/100 | Partial joins to nameless rooms should include heroes’ memberships. | send_join partial-state response includes hero memberships and their auth chains |
| MSC3939 | ✅ ● | 100/100 | Account locking | src/api/router/auth.rs locked_account_gate; M_USER_LOCKED 401 with soft_logou… |
| MSC3938 | ✅ ◐ | 80/80 | Remove deprecated keyId parameters from /keys endpoints | New /key/v2/server (no keyId) implemented; deprecated form retained for compat. |
| MSC3930 | 🟨 ◐ | 0/? | Polls push rules/notifications | complement: 0p/2f |
| MSC3925 | 🟨 ◐ | 50/50 | m.replace aggregation with full event | Tuwunel doesn’t replace content (good) but also lacks bundled m.replace aggre… |
| MSC3916 | ✅ ● | 90/100 | Authentication for media access, and new endpoint names | New /client/v1/media and /federation/v1/media auth endpoints implemented. |
| MSC3905 | ✅ ● | 100/100 | Application services should only be interested in local users | src/service/appservice/append.rs:66; local-user guard at the three event-inte… |
| MSC3882 | ✅ ● | 90/100 | Allow an existing session to sign in a new session | POST /login/get_token implemented with UIA |
| MSC3873 | ✅ ● | 100/100 | event_match dotted keys | dotted-key escape semantics handled in ruma flattened JSON |
| MSC3861 | 🟨 ◐ | 60/70 | Next-generation auth for Matrix, based on OAuth 2.0/OIDC | OIDC core endpoints implemented but not advertised as MSC3861 itself |
| MSC3860 | ❌ ◐ | 20/20 | Media Download Redirects | forwards allow_redirect to remote fetch but does not emit own redirect |
| MSC3856 | 🟨 ◐ | 40/60 | Threads List API | GET /threads route present but participated filter and latest-event order mis… |
| MSC3844 | ✅ ● | 100/100 | Remove “Mjolnir” (policy room) sharing mechanism | removal of unused Mjolnir share endpoint; Tuwunel never implemented it |
| MSC3828 | ✅ ● | 100/100 | Content Repository Cross Origin Resource Policy (CORP) Headers | media endpoints return Cross-Origin-Resource-Policy: cross-origin |
| MSC3827 | ✅ ● | 100/100 | Filtering of /publicRooms by room type | /publicRooms supports room_types filter and returns room_type |
| MSC3824 | 🟨 ◐ | 60/60 | OAuth 2.0 API aware clients | oauth_aware_preferred set in /login; SSO redirect action param ignored |
| MSC3823 | ✅ ● | 100/100 | Account Suspension | src/service/rooms/timeline/build.rs check_pdu_for_suspended_sender + auth.rs … |
| MSC3821 | ✅ ● | 90/100 | Update redaction rules, again | redact_in_place uses Ruma RedactionRules.V11 with keep third_party_invite.signed |
| MSC3820 | ✅ ● | 90/100 | Room Version 11 | v11 stable; redaction and auth rules dispatch via Ruma RoomVersionRules |
| MSC3818 | ✅ ● | 100/100 | Copy room type on upgrade | upgrade reuses old m.room.create content; type preserved by default |
| MSC3816 | ❌ ◐ | 10/10 | Clarify Thread Participation | BundledThread.current_user_participated hardcoded true on first reply only |
| MSC3787 | 🟨 ● | 70/? | Allowing knocks to restricted rooms | complement: 33p/14f |
| MSC3786 | ✅ ● | 100/100 | Add a default push rule to ignore m.room.server_acl events | server_acl predefined push rule via Ruma defaults |
| MSC3773 | ✅ ● | 100/100 | Notifications for threads | src/service/pusher/notification.rs:143 per-thread counts; src/api/client/sync… |
| MSC3771 | ✅ ● | 100/100 | Read receipts for threads | src/api/client/read_marker.rs validates+routes thread; receipt and private_re… |
| MSC3765 | 🟨 ◐ | 30/40 | Rich text in room topics | topic_block accepted via Ruma; createRoom only writes plain topic |
| MSC3758 | ✅ ● | 90/100 | Add event_property_is push rule condition kind | event_property_is dispatched via Ruma Ruleset::get_actions |
| MSC3743 | ✅ ● | 90/100 | Standardized error response for unknown endpoints | M_UNRECOGNIZED 404/405 fallback wired in router |
| MSC3715 | ✅ ● | 100/100 | Add a pagination direction parameter to /relations | dir parameter on /relations is parsed and used |
| MSC3706 | ✅ ● | 90/100 | Extensions to /_matrix/federation/v2/send_join/{roomId}/{eventId} for parti… | send_join supports omit_members, members_omitted, servers_in_room |
| MSC3667 | ✅ ● | 100/100 | Enforce integer power levels | integer_power_levels enforced via RoomVersionRules from V10+ |
| MSC3666 | 🟨 ● | 30/30 | Bundled aggregations for server side search | thread bundles already surface in /search responses via verbatim serializatio… |
| MSC3604 | ✅ ● | 100/100 | Room Version 10 | V10 supported; integer_power_levels and knock_restricted enforced |
| MSC3589 | ✅ ● | 100/100 | Room version 9 as a default | default_room_version defaults to V11 (exceeds V9) |
| MSC3582 | ✅ ● | 100/100 | Remove m.room.message.feedback | feedback removal; tuwunel never produces or dispatches on m.room.message.feed… |
| MSC3567 | ✅ ● | 100/100 | Allow requesting events from the start/end of the room history | from is optional; defaults to start/end based on dir |
| MSC3550 | 🟨 ◐ | 50/50 | Add HTTP 403 to possible profile lookup responses | federation 403 returned; client /profile still 404 only |
| MSC3442 | ✅ ● | 100/100 | move the prev_content key to unsigned | prev_content placed under unsigned in created/appended PDUs |
| MSC3440 | 🟨 ● | 60/70 | MSC3440 Threading via m.thread relation | [→ MSC3856] thread bundling, /threads, /relations with rel_type filter |
| MSC3419 | ✅ ○ | 100/100 | Guest State Events | no guest-specific gate on state-event send path; PL/auth_check applies unifor… |
| MSC3383 | ✅ ● | 100/100 | Include destination in X-Matrix Auth Header | X-Matrix destination field validated on inbound federation |
| MSC3381 | 🟨 ◐ | 0/? | Chat Polls | complement: 0p/2f |
| MSC3375 | ✅ ● | 100/100 | Room Version 9 | room v9 stable; redaction keeps join_authorised_via_users_server |
| MSC3316 | ✅ ● | 100/100 | Proposal to add timestamp massaging to the spec | appservice ts honored on /send and /state |
| MSC3289 | ✅ ● | 100/100 | Room Version 8 | room v8 listed stable; restricted join rule auth implemented |
| MSC3283 | ✅ ● | 100/100 | Expose enable_set_displayname, enable_set_avatar_url and enable_3pid_changes … | src/api/client/capabilities.rs explicitly emits m.set_displayname, m.set_avat… |
| MSC3267 | 🟨 ◐ | 50/50 | reference relationships | reference relations queryable via /relations; no m.relations bundling |
| MSC3266 | ✅ ● | 100/100 | Room Summary API | summary endpoint routed at unstable and (via Ruma) stable paths |
| MSC3231 | ✅ ● | 100/100 | Token Authenticated Registration | registration token UIA + validity endpoint implemented |
| MSC3173 | ✅ ● | 100/100 | Expose stripped state events to any potential joiner | summary_stripped includes recommended events incl create |
| MSC3083 | ✅ ● | 100/100 | Restricting room membership based on membership in other rooms | restricted_join_rule auth via RoomVersionRules; v8/v9 |
| MSC3069 | ✅ ◐ | 80/100 | Allow guests to use /account/whoami | whoami returns is_guest; uses is_deactivated heuristic |
| MSC3030 | 🟨 ● | 60/80 | Jump to date API endpoint | client and federation timestamp_to_event handlers; no remote fallback when lo… |
| MSC2998 | ✅ ● | 100/100 | Room Version 7 | V7 listed in STABLE_ROOM_VERSIONS; full knock support present |
| MSC2967 | ✅ ● | 80/90 | API scopes | urn:matrix:client:device:* scope honored; api:* scope advertised |
| MSC2966 | 🟨 ● | 60/80 | Usage of OAuth 2.0 Dynamic Client Registration in Matrix | dynamic client registration endpoint |
| MSC2965 | ✅ ● | 90/100 | OAuth 2.0 Authorization Server Metadata discovery | auth_issuer and auth_metadata routes return OAuth provider metadata |
| MSC2964 | ✅ ● | 90/100 | Usage of OAuth 2.0 authorization code grant and refresh token grant | OAuth2 authorize/token/refresh implemented |
| MSC2946 | ✅ ● | 90/100 | Spaces Summary | client and federation hierarchy endpoints implemented |
| MSC2918 | ✅ ● | 90/100 | Refresh tokens | /refresh, expires_in_ms, refresh_token in /login and /register |
| MSC2870 | ✅ ◐ | 100/100 | Protect server ACLs from redaction | redaction dispatches on RoomVersionRules.redaction; ruma MSC2870 enabled |
| MSC2867 | ✅ ◐ | 100/100 | Marking rooms as unread | client convention; account data type stored generically |
| MSC2858 | ✅ ● | 100/100 | Multiple SSO Identity Providers | identity_providers in /login flows; /login/sso/redirect/{idpId} routed |
| MSC2844 | ✅ ● | 90/90 | Using a global version number for the entire specification | src/api/client/versions.rs advertises v1.1 through v1.15 |
| MSC2832 | ✅ ● | 100/100 | Homeserver -> Application Service authorization header | src/service/appservice/request.rs sends Bearer header and query |
| MSC2788 | ✅ ● | 100/100 | Room version 6 as a default | default_default_room_version is V11 in src/core/config/mod.rs:3842 |
| MSC2778 | ✅ ● | 100/100 | Providing authentication method for appservice users | src/api/client/session/appservice.rs implements m.login.application_service |
| MSC2746 | 🟨 ○ | 40/40 | Improved Signalling for 1:1 VoIP | Events relayed; no specific server hooks |
| MSC2732 | ✅ ● | 100/100 | Olm fallback keys | src/api/client/keys/claim_keys.rs:86; upload, claim-fallback, sync-unused-lis… |
| MSC2705 | ❌ ◐ | 0/10 | Animated thumbnails | animated param accepted; thumbnails always PNG static |
| MSC2702 | ✅ ● | 100/100 | Content-Disposition usage in the media repo | Content-Disposition and inline allowlist enforced for media downloads, thumbn… |
| MSC2701 | ✅ ◐ | 80/90 | Media and the Content-Type relationship | Optional Content-Type accepted; stored and returned |
| MSC2689 | ✅ ◐ | 100/100 | Allow guests to operate in encrypted rooms | Auth treats guests like users; /members open |
| MSC2677 | ✅ ● | 80/90 | Annotations and Reactions | Duplicate annotation rejected; reactions plumbed |
| MSC2676 | 🟨 ● | 50/60 | Message editing | edits accepted/relayed; no m.replace bundle or new_content apply |
| MSC2675 | 🟨 ● | 50/60 | Serverside aggregations of message relationships | /relations exists; only m.thread bundling, no m.replace bundle |
| MSC2674 | ✅ ● | 90/100 | Event relationships | relates_to handled in append; rel_type tracked |
| MSC2666 | 🟨 ● | 60/70 | Get rooms in common with another user | src/api/client/unstable.rs:28 GET /unstable/uk.half-shot.msc2666/user/mutual_… |
| MSC2663 | ✅ ● | 100/100 | Errors for dealing with non-existent push rules | src/api/client/push.rs all 7 endpoints return NotFound |
| MSC2659 | 🟨 ● | 70/90 | Application service ping endpoint | src/api/client/appservice.rs:11 calls AS /_matrix/app/v1/ping |
| MSC2611 | ✅ ● | 100/100 | Remove m.login.token User-Interactive Authentication type from the specific… | AuthType::Token UIAA not advertised; m.login.token login is unrelated |
| MSC2610 | ✅ ● | 100/100 | Remove m.login.oauth2 User-Interactive Authentication type from the specifi… | AuthType::OAuth2 not advertised; only Password/Sso/Jwt flows |
| MSC2540 | ❌ ◐ | 0/0 | Stricter event validation: JSON compliance | ruma exposes strict_canonical_json flag; Tuwunel does not enforce floats reje… |
| MSC2526 | ✅ ● | 100/100 | Add ability to delete key backups | src/api/client/backup.rs:134 delete_backup_version_route |
| MSC2457 | ✅ ● | 100/100 | Invalidating devices during password modification | src/api/client/account.rs:41 honors body.logout_devices |
| MSC2454 | ✅ ● | 90/90 | User-Interactive Authentication for SSO-backed homeserver | src/api/router/auth/uiaa.rs:53 sso_flow; sso/uiaa.rs serves fallback |
| MSC2451 | ✅ ● | 100/100 | Remove the query_auth federation endpoint | No /query_auth route registered in src/api/router.rs |
| MSC2432 | ✅ ◐ | 80/90 | Updated semantics for publishing room aliases | alt_aliases wired; canonical_alias resolve check; rooms/{}/aliases route present |
| MSC2414 | ✅ ● | 100/100 | Make reason and score optional for reporting content | reason and score are Option in ruma report types; route accepts both |
| MSC2409 | 🟨 ● | 70/70 | Proposal to send typing, presence and receipts to appservices | typing+receipt EDUs sent to AS; presence not forwarded |
| MSC2403 | ✅ ● | 90/90 | Add “knock” feature | Knock CS+SS endpoints, sync key, public-rooms join_rule all wired |
| MSC2367 | ✅ ● | 100/100 | Allowing Reasons in all Membership Events | reason field handled in invite/leave/kick/ban/unban/join membership routes |
| MSC2334 | ✅ ● | 100/100 | MSC2334 - Change defaul… | Default room version is V11, well past V5 |
| MSC2285 | ✅ ● | 90/100 | Private read receipts | src/api/client/read_marker.rs handles ReadPrivate via private_read_set |
| MSC2249 | ✅ ● | 90/100 | Require users to have visibility on an event when submitting reports | src/api/client/report.rs:173 verifies sender is room member; PDU lookup gated |
| MSC2246 | ✅ ● | 100/100 | Asynchronous media uploads | async media routes wired; create_pending, upload_pending, error codes present |
| MSC2244 | ❌ ● | 0/0 | Mass redactions | Single-target redactions only; no array redacts handling |
| MSC2240 | ✅ ● | 100/100 | Room Version 6 | V6 in STABLE_ROOM_VERSIONS; v6 auth rules and rules engine implemented |
| MSC2209 | ✅ ● | 100/100 | Update auth rules to check notifications key in m.room.power_levels | limit_notifications_power_levels enforced for v6+ |
| MSC2197 | ✅ ● | 100/100 | Search Filtering in Public Room Directory over Federation | POST /_matrix/federation/v1/publicRooms with filter implemented |
| MSC2181 | ✅ ● | 100/100 | Add an Error Code for Signaling a Deactivated User | M_USER_DEACTIVATED returned by login paths |
| MSC2176 | ✅ ● | 100/100 | Update the redaction rules | redact_in_place uses room_version_rules.redaction |
| MSC2175 | ✅ ● | 100/100 | Remove the creator field from m.room.create events | creator() falls back to sender when use_room_create_sender |
| MSC2174 | ✅ ● | 100/100 | move the redacts property to content | src/core/matrix/event/redact.rs handles redacts move per room rules |
| MSC2077 | ✅ ● | 100/100 | Room version 5 | src/core/config/room_version.rs:7; v5 unstable but supported |
| MSC2076 | ❌ ◐ | 0/10 | Enforce key-validity periods when validating event signatures | minimum_valid_until_ts passed for fetches; per-event ts check absent |
| MSC2033 | ✅ ● | 100/100 | Proposal to include device IDs in /account/whoami | src/api/client/account.rs:74 returns device_id in whoami response |
| MSC2002 | ✅ ● | 100/100 | MSC 2002 - Rooms V4 | v4 in supported_room_versions; ruma rules implement v4 |
| MSC1983 | ✅ ● | 100/100 | Proposal to add reasons for leaving a room | src/api/client/membership/leave.rs:21 passes body.reason to leave |
| MSC1954 | ✅ ● | 100/100 | Remove prev_content from the essential keys list | merged; identical to MSC1953; ruma redact omits prev_content |
| MSC1946 | ✅ ◐ | 80/90 | Secure Secret Storage and Sharing | generic account_data + to-device pipe carry secret storage/sharing |
| MSC1930 | ✅ ● | 100/100 | Proposal to add a default push rule for m.room.tombstone events | ruma Ruleset::server_default includes ConditionalPushRule::tombstone() |
| MSC1929 | 🟨 ● | 60/80 | MSC1929 Homeserver Admin Contact and Support page | /.well-known/matrix/support implemented; only single contact via config (no a… |
| MSC1884 | ✅ ● | 100/100 | Proposal to replace slashes in event IDs | room v4 supported via ruma EventIdFormatVersion::V3 (URL-safe base64) |
| MSC1866 | 🟨 ○ | 60/70 | MSC 1866 - Unsupported Room Version Error Code for Invites | federation invite errors propagated; not explicitly mapped |
| MSC1831 | ✅ ● | 100/100 | Proposal to do SRV lookups after .well-known to discover homeservers | src/service/resolver/actual.rs:79 well-known before SRV |
| MSC1819 | ✅ ● | 100/100 | Remove references to presence lists | duplicate of MSC1818; presence lists not implemented |
| MSC1812 | ✅ ● | 100/100 | MSC 1813 - Federation Make Membership Room Version | src/api/server/make_leave.rs:34 and make_join.rs:52 set room_version |
| MSC1804 | ✅ ● | 100/100 | Proposal for advertising capable room versions to clients | src/api/client/capabilities.rs sets RoomVersionsCapability |
| MSC1802 | ✅ ● | 100/100 | Remove the ‘200’ value from some federation responses | src/api/server/send_join.rs:30 and send_leave.rs:15 handle v2 |
| MSC1794 | ✅ ● | 100/100 | MSC 1794 - Federation v2 Invite API | src/api/server/invite.rs:28 implements PUT /federation/v2/invite |
| MSC1772 | ✅ ● | 90/90 | Proposal for Matrix “spaces” (formerly known as “groups as rooms (take 2)”) | spaces implemented; src/api/client/space.rs hierarchy + room create with type |
| MSC1767 | ❌ ◐ | 0/0 | Extensible events in Matrix | no extensible-events handling; relies on generic event relay |
| MSC1759 | ❌ ◐ | 10/20 | MSC 1759 - Rooms V2 | v2 algorithm in use for v3+; v2 itself not in supported_room_versions |
| MSC1756 | ✅ ● | 90/100 | Cross-signing devices with device signing keys | src/api/client/keys/upload_signing_keys.rs and upload_signatures.rs implement… |
| MSC1753 | ✅ ● | 100/100 | client-server capabilities API | src/api/client/capabilities.rs handles GET /capabilities incl m.change_password |
| MSC1730 | ✅ ● | 100/100 | Mechanism for redirecting to an alternative server during login | src/api/client/session/mod.rs:176 sets well_known on login response |
| MSC1721 | ✅ ● | 100/100 | Rename m.login.cas to m.login.sso | src/api/client/session/sso.rs and uiaa.rs advertise m.login.sso |
| MSC1717 | ✅ ◐ | 90/100 | Key verification mechanisms | to_device transport carries m.key.verification.* events |
| MSC1711 | ✅ ◐ | 100/100 | X.509 certificate verification for federation connections | reqwest+rustls; tls_fingerprints not exposed; standard CA validation |
| MSC1708 | ✅ ● | 90/100 | .well-known support for server name resolution | src/service/resolver/well_known.rs; resolver/actual.rs ordering matches spec |
| MSC1704 | ✅ ● | 100/100 | matrix.to permalink navigation | server-side requirement is via= on /join; src/api/client/membership/join.rs:79 |
| MSC1693 | ✅ ● | 100/100 | Specify how to handle rejected events in new state res | rejected event handling in iterative auth check matches MSC1442 amendment |
| MSC1692 | ❌ ◐ | 0/10 | Terms of service at registration | AuthType::Terms exists in Ruma but Tuwunel’s register flow does not advertise… |
| MSC1659 | ✅ ● | 90/100 | Changing Event IDs to be Hashes | reference_hash event IDs; v3 in UNSTABLE_ROOM_VERSIONS; auth_events as list-o… |
| MSC1501 | ✅ ● | 90/90 | Room version upgrades | upgrade endpoint present; tombstone, predecessor, PL freeze all implemented |
| MSC1466 | ✅ ● | 100/100 | Soft Remote Logout Proposal | soft_logout=true returned for expired tokens in 401 responses |
| MSC1442 | ✅ ● | 90/100 | State Resolution: Reloaded | state res v2 implemented in src/service/rooms/state_res/resolve.rs |
| MSC1219 | 🟨 ● | 70/100 | Storing megolm keys serverside | key backup endpoints fully implemented in src/api/client/backup.rs |
Spec compliance gaps
Merged MSCs (in the live Matrix spec) that Tuwunel does not fully implement. These are the highest-priority items to fix for spec compliance.
| MSC | Status | Correct/Impl | Title | Note |
|---|---|---|---|---|
| MSC4291 | 🟨 ● | 80/90 | Room IDs as hashes of the create event | hydra.11 room id format and auth rules in event_auth, pdu format checks |
| MSC1219 | 🟨 ● | 70/100 | Storing megolm keys serverside | key backup endpoints fully implemented in src/api/client/backup.rs |
| MSC2409 | 🟨 ● | 70/70 | Proposal to send typing, presence and receipts to appservices | typing+receipt EDUs sent to AS; presence not forwarded |
| MSC2659 | 🟨 ● | 70/90 | Application service ping endpoint | src/api/client/appservice.rs:11 calls AS /_matrix/app/v1/ping |
| MSC3787 | 🟨 ● | 70/? | Allowing knocks to restricted rooms | complement: 33p/14f |
| MSC4133 | 🟨 ● | 70/80 | Extending User Profile API with Custom Key:Value Pairs | GET/PUT/DELETE profile field endpoints routed at unstable prefix |
| MSC4380 | 🟨 ● | 70/70 | Invite blocking | phase A landed (invite-creating endpoints gated, M_INVITE_BLOCKED 403); phase… |
| MSC1866 | 🟨 ○ | 60/70 | MSC 1866 - Unsupported Room Version Error Code for Invites | federation invite errors propagated; not explicitly mapped |
| MSC1929 | 🟨 ● | 60/80 | MSC1929 Homeserver Admin Contact and Support page | /.well-known/matrix/support implemented; only single contact via config (no a… |
| MSC2666 | 🟨 ● | 60/70 | Get rooms in common with another user | src/api/client/unstable.rs:28 GET /unstable/uk.half-shot.msc2666/user/mutual_… |
| MSC2966 | 🟨 ● | 60/80 | Usage of OAuth 2.0 Dynamic Client Registration in Matrix | dynamic client registration endpoint |
| MSC3030 | 🟨 ● | 60/80 | Jump to date API endpoint | client and federation timestamp_to_event handlers; no remote fallback when lo… |
| MSC3440 | 🟨 ● | 60/70 | MSC3440 Threading via m.thread relation | [→ MSC3856] thread bundling, /threads, /relations with rel_type filter |
| MSC3824 | 🟨 ◐ | 60/60 | OAuth 2.0 API aware clients | oauth_aware_preferred set in /login; SSO redirect action param ignored |
| MSC3861 | 🟨 ◐ | 60/70 | Next-generation auth for Matrix, based on OAuth 2.0/OIDC | OIDC core endpoints implemented but not advertised as MSC3861 itself |
| MSC2675 | 🟨 ● | 50/60 | Serverside aggregations of message relationships | /relations exists; only m.thread bundling, no m.replace bundle |
| MSC2676 | 🟨 ● | 50/60 | Message editing | edits accepted/relayed; no m.replace bundle or new_content apply |
| MSC3267 | 🟨 ◐ | 50/50 | reference relationships | reference relations queryable via /relations; no m.relations bundling |
| MSC3550 | 🟨 ◐ | 50/50 | Add HTTP 403 to possible profile lookup responses | federation 403 returned; client /profile still 404 only |
| MSC3925 | 🟨 ◐ | 50/50 | m.replace aggregation with full event | Tuwunel doesn’t replace content (good) but also lacks bundled m.replace aggre… |
| MSC4025 | 🟨 ● | 50/50 | Local user erasure requests | phase A landed (account-data wipe); phase B (per-event visibility gate) deferred |
| MSC4191 | 🟨 ◐ | 50/80 | Account management for OAuth 2.0 API | metadata wired but action names diverge from MSC |
| MSC2746 | 🟨 ○ | 40/40 | Improved Signalling for 1:1 VoIP | Events relayed; no specific server hooks |
| MSC3856 | 🟨 ◐ | 40/60 | Threads List API | GET /threads route present but participated filter and latest-event order mis… |
| MSC3666 | 🟨 ● | 30/30 | Bundled aggregations for server side search | thread bundles already surface in /search responses via verbatim serializatio… |
| MSC3765 | 🟨 ◐ | 30/40 | Rich text in room topics | topic_block accepted via Ruma; createRoom only writes plain topic |
| MSC4277 | 🟨 ◐ | 30/40 | Harmonizing the reporting endpoints | event and room report endpoints exist; user report endpoint absent |
| MSC3381 | 🟨 ◐ | 0/? | Chat Polls | complement: 0p/2f |
| MSC3930 | 🟨 ◐ | 0/? | Polls push rules/notifications | complement: 0p/2f |
| MSC4311 | 🟨 ◐ | 0/? | Ensuring the create event is available on invites | complement: 0p/1f |
| MSC3860 | ❌ ◐ | 20/20 | Media Download Redirects | forwards allow_redirect to remote fetch but does not emit own redirect |
| MSC1759 | ❌ ◐ | 10/20 | MSC 1759 - Rooms V2 | v2 algorithm in use for v3+; v2 itself not in supported_room_versions |
| MSC3816 | ❌ ◐ | 10/10 | Clarify Thread Participation | BundledThread.current_user_participated hardcoded true on first reply only |
| MSC1692 | ❌ ◐ | 0/10 | Terms of service at registration | AuthType::Terms exists in Ruma but Tuwunel’s register flow does not advertise… |
| MSC1767 | ❌ ◐ | 0/0 | Extensible events in Matrix | no extensible-events handling; relies on generic event relay |
| MSC2076 | ❌ ◐ | 0/10 | Enforce key-validity periods when validating event signatures | minimum_valid_until_ts passed for fetches; per-event ts check absent |
| MSC2244 | ❌ ● | 0/0 | Mass redactions | Single-target redactions only; no array redacts handling |
| MSC2540 | ❌ ◐ | 0/0 | Stricter event validation: JSON compliance | ruma exposes strict_canonical_json flag; Tuwunel does not enforce floats reje… |
| MSC2705 | ❌ ◐ | 0/10 | Animated thumbnails | animated param accepted; thumbnails always PNG static |
| MSC3980 | ❌ ● | 0/0 | Dotted Field Consistency | blocked on a missing prerequisite: Tu does not implement event_fields filteri… |
| MSC4335 | ❌ ● | 0/0 | M_USER_LIMIT_EXCEEDED error code | M_USER_LIMIT_EXCEEDED error code not used |
| MSC4341 | ❌ ● | 0/0 | Support for RFC 8628 Device Authorization Grant | OAuth Device Authorization Grant (RFC 8628) not advertised |
Open
Sorted by MSC number, highest first. Out-of-scope rows are listed in the Out of scope section.
| MSC | Status | Correct/Impl | Title | Note |
|---|---|---|---|---|
| MSC4474 | ✅ ○ | 100/100 | Clarify usage of content blocks in Extensible Events | Spec clarification for MSC1767; HS does opaque content passthrough. |
| MSC4473 | ❌ ● | 0/0 | Proxied room alias resolution | No federation v2 query/directory; no signing/proxy logic. |
| MSC4472 | ❌ ● | 0/0 | Deprecated room version kind | No deprecated stability kind in room_versions capability. |
| MSC4471 | ✅ ● | 100/100 | Streaming ephemeral event updates for room events | MSC explicitly requires no HS work; to-device transport suffices. |
| MSC4470 | ❌ ● | 0/0 | Routing reports to non-local destinations | /report does not accept must_send_to; no fan-out. |
| MSC4469 | ❌ ● | 0/0 | Reporting to remote servers (EDU approach) | m.report EDU not defined or routed. |
| MSC4468 | ✅ ◐ | 90/90 | Reporting to communities (via to-device) | Pure state-event plus to-device passthrough; no HS-specific work. |
| MSC4467 | ❌ ● | 0/0 | Improved Room Upgrade API | v3 upgrade only; no v4 endpoint, capability, or migration_schema. |
| MSC4466 | ✅ ● | 100/100 | Altering profile change propagation | propagate_to query param honored on set/delete_displayname, set/delete_avatar… |
| MSC4464 | ❌ ● | 0/0 | verifiable links in profile | No /verify_profile_connection endpoint or verification backend. |
| MSC4462 | ❌ ◐ | 10/10 | Links in Profile | Incidental MSC4133 passthrough; no m.connections parsing. |
| MSC4461 | ✅ ◐ | 100/100 | Storing per-message profiles for users | Pure account data passthrough; generic CS account-data covers it. |
| MSC4460 | ❌ ● | 0/0 | Extensible Events - Alternative unstable support | Client-side hybrid extensible-events rendering rules; no Tuwunel dispatch. |
| MSC4459 | ❌ ● | 0/0 | Image pack references | Client-side image pack reference field; homeserver passes events through tran… |
| MSC4458 | ✅ ◐ | 80/80 | Handling incoming JSON in the server-server API | Incoming PDUs deserialized via serde_json into CanonicalJsonObject |
| MSC4457 | ❌ ● | 0/0 | Generic reporting API | No /_matrix/client/v1/safety/report endpoint |
| MSC4453 | ❌ ● | 0/0 | Deprecate old room versions | v3-v5 marked unstable; v6-v9 still stable; create/upgrade not gated |
| MSC4452 | ✅ ● | 100/100 | Preview URL capabilities API | src/api/client/capabilities.rs:85; enabled from preview allowlist gate |
| MSC4450 | ❌ ● | 0/0 | Identity Provider selection for User-Interactive Authentication with Legacy S… | UIAA SSO fallback derives idp from session, not idp_id query |
| MSC4449 | ❌ ● | 0/0 | Updated /members filtering | Single membership filter only; no array support, no mutual-exclusion error |
| MSC4448 | ❌ ● | 0/0 | Preview URL Site Logos | No matrix:site_logo or msc4448:site_logo in preview_url response |
| MSC4447 | ❌ ● | 0/0 | Move OpenID userinfo endpoint out of /_matrix/federation | Old /federation/v1/openid/userinfo present; new /_matrix/openid/v1/userinfo n… |
| MSC4446 | ❌ ● | 0/0 | Allow moving the fully read marker to older events | No allow_backward field; no monotonicity check on m.fully_read |
| MSC4445 | ❌ ◐ | 0/0 | Clarify /sync timeline order | No msc4445 unstable_features flags advertised |
| MSC4440 | ❌ ● | 0/0 | Profile Biography via Global Profiles | Generic MSC4133 passthrough only; no m.biography validation |
| MSC4439 | ❌ ● | 0/0 | Encryption key URIs in /.well-known/matrix/support | No pgp_key field on /.well-known/matrix/support contacts |
| MSC4438 | ✅ ● | 100/100 | Message bookmarks via account data | Pure account-data convention; existing endpoints store arbitrary types |
| MSC4437 | ❌ ● | 0/0 | Endpoint to replace entire profile | No PUT /_matrix/client/v3/profile/{userId} replace-all endpoint |
| MSC4436 | ✅ ● | 100/100 | Make server ACLs case insensitive | Ruma is_allowed uses WildMatch::new_case_insensitive |
| MSC4435 | ❌ ● | 0/0 | Room slowmode | No m.room.slowmode handling |
| MSC4433 | ❌ ● | 0/0 | Image Packs and Room Upgrades | Room upgrade does not transfer m.room.image_pack or update m.image_pack.rooms |
| MSC4432 | ❌ ● | 0/0 | Server-wide room name overrides | No m.room.name.server_wide propagation; no capability |
| MSC4431 | ❌ ● | 0/0 | Personalised room name overrides | Server side passively allows m.room.name.private as account data |
| MSC4430 | ❌ ● | 0/0 | Member Keys | No member-key room version, no /member_key federation endpoint |
| MSC4429 | ❌ ● | 0/0 | Profile Updates for Legacy Sync | No top-level users field in /sync; no profile_fields filter |
| MSC4428 | ❌ ● | 0/0 | Stable identifiers for Room Members | No member_info or unsigned.stable_id added to events or sync |
| MSC4427 | ❌ ● | 0/0 | Custom banners for user profiles | No m.banner_url or chat.commet.profile_banner support |
| MSC4426 | ❌ ◐ | 20/20 | User Status Profile Fields | Profile keys passthrough via MSC4133 endpoints; no specific m.status/m.call v… |
| MSC4425 | ❌ ● | 0/0 | Ephemeral media | no ephemeral query param; no DELETE on /_matrix/client/v1/media/…/…. |
| MSC4423 | ✅ ● | 100/100 | Undefine order of room directory | undefines /publicRooms ordering; Tuwunel’s existing order is now compatible. |
| MSC4420 | ❌ ● | 0/0 | Duplicate one-time key error response for /keys/upload | add_one_time_key silently overwrites; no M_DUPLICATE_ONE_TIME_KEY emitted. |
| MSC4418 | ✅ ● | 100/100 | Make destination a required server authentication field | destination required on inbound and outbound; cited verbatim in MSC. |
| MSC4417 | ❌ ● | 0/0 | URL Previews via Appservices | client preview_url exists; no appservice fan-out or namespace check. |
| MSC4416 | ❌ ● | 0/0 | Optionally requiring policy server signatures in a room | depends on MSC4284; no policy-server signature checks anywhere. |
| MSC4413 | ✅ ◐ | 100/100 | Remove private join_rule | private join_rule treated as unknown; effective semantics already aligned. |
| MSC4406 | 🟨 ● | 70/70 | M_SENDER_IGNORED error code | src/api/client/{room/event.rs:74,context.rs:86,relations.rs:175}; M_SENDER_IG… |
| MSC4403 | ❌ ● | 0/0 | Forbid event_id on PDUs received over federation | new room version forbidding event_id on PDUs; com.nhjkl.msc4403.opt2 absent. |
| MSC4401 | ❌ ◐ | 0/0 | Publishing client capabilities via profiles | generic profile keys exist; logout cleanup of client_capability missing. |
| MSC4400 | ❌ ● | 0/0 | Remove the depth field from PDUs | new room version removing depth field; com.nhjkl.msc4400.opt1 absent. |
| MSC4396 | ❌ ● | 0/0 | Inline linked media | no multipart/mixed event-with-media; no m.media mixin or M_GONE wired. |
| MSC4390 | ❌ ● | 0/0 | Room Blocking API | [→ MSC4375?] no client admin endpoints for room block/delete; only federation… |
| MSC4388 | ❌ ● | 0/0 | Secure out-of-band channel for sign in with QR | no /_matrix/client/v1/rendezvous endpoints; rendezvous API absent. |
| MSC4387 | ❌ ● | 0/0 | M_SAFETY error code | M_SAFETY errcode not used anywhere in src/; no harms field handling. |
| MSC4384 | 🟨 ◐ | ?/50 | Supporting alternative room directory sorting | Largest-first sort is hardcoded; no alt-sort hook |
| MSC4383 | ✅ ● | 100/100 | Client-Server Discovery of Server Version | src/api/client/versions.rs:33; populates Server { name, version, compiler } o… |
| MSC4382 | ❌ ● | 0/0 | Peppered hash verification for E2EE content moderation | No verification_hash check on report endpoint |
| MSC4375 | ❌ ● | 0/0 | Admin Room Management | No /_matrix/client/v1/admin/rooms/* endpoints |
| MSC4373 | ✅ ● | 80/80 | Server opt-out of specific EDU types | src/api/server/edu_types.rs:9; advertises types tied to allow_incoming_* conf… |
| MSC4371 | ❌ ● | 0/0 | On the elimination of federation transactions. | No PUT /_matrix/federation/v2/send/{eventId|eduId} endpoint |
| MSC4370 | ❌ ● | 0/0 | Federation endpoint for retrieving current extremities | No /_matrix/federation/v1/extremities endpoint |
| MSC4369 | ❌ ● | 0/10 | M_CAPABILITY_NOT_ENABLED error code for when capability is not enabled on an … | Endpoints exist but return M_FORBIDDEN/Unknown not M_CAPABILITY_NOT_ENABLED |
| MSC4368 | ❌ ● | 0/0 | Combine definitions of M_RESOURCE_LIMIT_EXCEEDED error code and m.server_noti… | M_RESOURCE_LIMIT_EXCEEDED unused; no limit_type field |
| MSC4367 | ❌ ● | 0/0 | via routes in the published room directory | PublishedRoomsChunk has no via field |
| MSC4366 | ❌ ● | 0/0 | Resident servers in and around the room directory | publicRooms not filtered to rooms with joined members |
| MSC4365 | ❌ ● | 0/0 | Canonical ignore list rooms | No ignored_user_list_rooms server-side filtering |
| MSC4363 | ❌ ● | 0/0 | OAuth step up authentication | No M_INSUFFICIENT_USER_AUTHENTICATION error or acr_values |
| MSC4362 | ❌ ● | 0/0 | Simplified Encrypted State Events | No encrypt_state_events handling in m.room.encryption |
| MSC4361 | ✅ ● | 100/100 | Non-federating Membership Authorization Rule Amendments | src/service/rooms/state_res/event_auth/room_member.rs:56; reject m.room.membe… |
| MSC4360 | ❌ ● | 0/0 | Threads extension to Sliding Sync | No /thread_updates endpoint or threads sliding sync extension |
| MSC4358 | ❌ ● | 0/0 | Out of room server discovery | No /discover_common_rooms federation endpoint |
| MSC4354 | ❌ ● | 0/0 | Sticky Events | No sticky events handling on send or sync |
| MSC4353 | ❌ ● | 0/0 | Per-origin linear chain | No origin_predecessor field or per-origin chain validation |
| MSC4352 | ❌ ● | 0/0 | Customizable HTTPS permalink base URLs via server discovery | No permalink_base_url in /.well-known/matrix/client output |
| MSC4351 | ✅ ● | 100/100 | Odd Context Limits | Context handler biases remainder to events_after via div_ceil(2) |
| MSC4350 | ❌ ● | 0/0 | Permitting encryption impersonation for appservices | No impersonator field in device keys, no /keys/query handling |
| MSC4349 | ❌ ● | 0/0 | Causal barriers and enforcement | causal barrier terminology and deferred authorization not adopted |
| MSC4348 | ❌ ● | 0/0 | Portable and serverless accounts in rooms | portable accounts (account keys); not implemented |
| MSC4345 | ❌ ● | 0/0 | Server key identity and room membership | server key as room identity; massive auth-rule changes; not implemented |
| MSC4344 | ❌ ● | 0/0 | Strike deprecated SRV service name. | deprecated _matrix._tcp SRV still queried |
| MSC4343 | ❌ ● | 0/0 | Making mass redactions use a new event type | m.room.redactions (mass redactions) event not used; depends on MSC2244 |
| MSC4342 | ❌ ● | 0/0 | Limiting the number of devices per user ID | 30-device limit and M_TOO_MANY_DEVICES not enforced |
| MSC4340 | ❌ ● | 0/0 | Prompts and partial commands for in room commands. | bot command prompts; client-side concern, no server changes |
| MSC4339 | ❌ ● | 0/0 | Allow the user directory to return full profiles | user_directory v4 with profile_fields not implemented |
| MSC4337 | ❌ ● | 0/0 | Appservice API to supplement user profiles | appservice profile supplement endpoint not queried |
| MSC4334 | ❌ ● | 0/0 | Add m.room.language state event. | m.room.language state event; not whitelisted/handled specially |
| MSC4333 | ❌ ● | 0/0 | Room state API for moderation bots | moderation bot state event; client-side concern |
| MSC4332 | ❌ ● | 0/0 | In-room bot commands | in-room bot commands; client-side concern, no server changes |
| MSC4331 | ❌ ● | 0/0 | Device Account Data | per-device account data routes not implemented |
| MSC4330 | 🟨 ◐ | 50/50 | specify HTTP and TLS versions which must be supported | HTTP/2 via axum/hyper available; TLS 1.2+ via rustls; not enforced as MUST |
| MSC4329 | ❌ ● | 0/0 | Inviting with authorization | federation /v3/invite with create event in state not implemented |
| MSC4325 | ❌ ● | 0/0 | Presence privacy | presence privacy filtering by m.presence_sharing_config not implemented |
| MSC4324 | ✅ ◐ | 80/80 | Fixing MSC4289’s power level for tombstones | tombstone PL=150 set; matches highest-anchored intent for default config |
| MSC4322 | ❌ ● | 0/0 | Simple Media Self-Redaction | [→ MSC3911?] media self-redaction; no /media/redact endpoint or EDU |
| MSC4321 | ❌ ● | 0/0 | Policy Room Upgrade Semantics | policy room upgrade move/transition semantics not handled |
| MSC4320 | ❌ ● | 0/0 | Rich Presence | Rich Presence m.rpc; no support for activity/media profile field |
| MSC4319 | ❌ ● | 0/0 | Room member events for invite and knock rooms in the /sync response | state key in InvitedRoom/KnockedRoom; not added to /sync responses |
| MSC4310 | ❌ ◐ | 10/10 | MatrixRTC decline m.rtc.notification | event-only MSC; ruma feature enabled, no homeserver-specific behavior |
| MSC4309 | ❌ ● | 0/0 | Finalised delayed events on sync | finalised delayed events on /sync; depends on MSC4140; no impl |
| MSC4308 | 🟨 ◐ | 0/? | Thread Subscriptions extension to Sliding Sync | complement: 0p/3f |
| MSC4306 | 🟨 ● | 8/? | Thread Subscriptions | complement: 1p/12f |
| MSC4305 | ❌ ● | 0/0 | Pushed Authorization Requests (PARs) for OAuth authentication | OIDC auth_metadata lacks PAR endpoint fields |
| MSC4303 | ❌ ● | 0/0 | Disallowing non-compliant user IDs in rooms | no future room version banning non-compliant user IDs |
| MSC4298 | ❌ ● | 0/0 | Room version components for ‘Redact on ban’ | no future room version protecting redact_events from redaction |
| MSC4293 | ❌ ● | 0/0 | Redact on kick/ban | MSC4293 commit lives only on Continuwuity branches; current tree has no redac… |
| MSC4282 | ❌ ● | 0/0 | Hint that a /rooms/{room_id}/messages request is interactive | no interactive query parameter on /messages |
| MSC4279 | ❌ ● | 0/0 | Server notice rooms | no notice room presets, no leave_rules, no server_notice room type filter |
| MSC4276 | ❌ ● | 0/0 | Soft unfailure for self redactions | no self-redaction soft-fail bypass |
| MSC4271 | ❌ ◐ | 0/0 | Recommended enabled-ness for default push rules | no admin override knob; uses Ruma defaults verbatim |
| MSC4266 | ❌ ● | 0/0 | Policies in /.well-known/matrix/support | policies field not added to /.well-known/matrix/support |
| MSC4265 | ❌ ◐ | 10/10 | Data Protection Officer contact in /.well-known/matrix/support | support_role configurable; MSC role string accepted as Custom |
| MSC4264 | ❌ ● | 0/0 | Tokens for Contacting Accounts or Joining Semi-Public Rooms | Tokens for contact / semi-public-room joins not implemented |
| MSC4263 | ❌ ◐ | 10/10 | Preventing MXID enumeration via key queries | MUST floor met implicitly; MAY restriction unused |
| MSC4262 | ❌ ● | 0/0 | Sliding Sync Extension: Profile Updates | Sliding-sync profiles extension not implemented |
| MSC4259 | ❌ ● | 0/0 | Profile Update EDUs for Federation | m.profile EDU broadcast not implemented |
| MSC4258 | ❌ ● | 0/0 | Federated User Directory | Federated user_directory/search not implemented |
| MSC4257 | ❌ ● | 0/0 | Profiles Arent Auth: Move profile contents to a separate event | m.room.member.profile separate event not supported |
| MSC4256 | ❌ ● | 0/0 | RFC 9420 MLS mode Matrix | MLS mode rooms not implemented |
| MSC4255 | ❌ ● | 0/0 | Bulk Profile Updates | Bulk PUT/PATCH /profile not implemented |
| MSC4250 | ❌ ● | 0/0 | Authenticated media v2 (Cookie authentication for Client-Server API) | set_auth_cookie media auth not implemented |
| MSC4249 | ✅ ● | 100/100 | Removal of legacy media endpoints | allow_legacy_media defaults to false; legacy disabled |
| MSC4247 | ❌ ◐ | 10/10 | User Pronouns | MSC4133 generic profile fields cover m.pronouns transparently |
| MSC4246 | ❌ ● | 0/0 | Sending to-device messages as/to a server | Empty-localpart server addressing for to-device absent |
| MSC4245 | ❌ ● | 0/0 | Immutable encryption algorithm | encryption_algorithm in m.room.create not honored |
| MSC4244 | ❌ ● | 0/0 | RFC 9420 MLS for Matrix | MLS for Matrix not implemented |
| MSC4243 | ❌ ● | 0/0 | User ID localparts as Account Keys | Account keys / federation query/accounts not implemented |
| MSC4242 | ❌ ● | 0/0 | State DAGs | State DAGs not implemented; uses standard auth chain |
| MSC4235 | ❌ ● | 0/0 | via query param for hierarchy endpoint | hierarchy endpoint lacks via query parameter |
| MSC4234 | ❌ ● | 0/0 | Update app badge counts when rooms are read | cleared_notifs read-receipt flag not handled |
| MSC4233 | ❌ ● | 0/0 | Remembering which server a user knocked through | knock_servers field in /sync not added; no via tracking |
| MSC4232 | ❌ ● | 0/0 | Attribute-Based Access Control (ABAC) | ABAC permissions model; no room version implements it |
| MSC4228 | ❌ ● | 0/0 | Search Redirection | optional 403 search redirection not used |
| MSC4227 | ❌ ● | 0/0 | Audio based quick login | no MSC4108 rendezvous support; audio/DTMF login absent |
| MSC4226 | ❌ ● | 0/0 | Reports as rooms | reports-as-rooms (m.report room type) not implemented |
| MSC4224 | ❌ ● | 0/0 | CBOR Serialization | application/cbor content negotiation not implemented |
| MSC4223 | ❌ ● | 0/0 | Error code for disallowing threepid unbinding | 3pid unbind/delete endpoints not implemented at all |
| MSC4221 | ✅ ● | 100/100 | Room Banners | event-only; passthrough |
| MSC4220 | ❌ ● | 0/0 | Local call rejection (m.call.reject_locally) | event-only; m.call.reject_locally not interpreted |
| MSC4218 | ❌ ● | 0/0 | Improving performance of profile changes | synthetic events / m.room.user_profile not implemented |
| MSC4211 | ✅ ● | 100/100 | WebXDC on Matrix | event-only; passthrough |
| MSC4208 | 🟨 ◐ | 40/50 | Adding User-Defined Custom Fields to User Global Profiles | custom profile fields work; u.* namespace not validated |
| MSC4207 | ❌ ● | 0/0 | Media identifier moderation policy | m.policy.rule.mxc not interpreted |
| MSC4206 | ❌ ● | 0/0 | Moderation policy auditing and context | m.policy.rule.context not interpreted server-side |
| MSC4205 | ❌ ● | 0/0 | Hashed moderation policy entities | hashed entity policies not interpreted |
| MSC4204 | ❌ ● | 0/0 | m.takedown moderation policy recommendation | no m.takedown recommendation handling |
| MSC4203 | 🟨 ● | 10/20 | Sending to-device events to appservices | to_device field wired in transaction body but always empty |
| MSC4202 | ❌ ◐ | 20/20 | Reporting User Profiles | client report endpoint exists; federation forwarding absent |
| MSC4201 | ❌ ● | 0/10 | Profiles as Rooms v2 | only generic /profile/{user} exists; no roomID profile lookup |
| MSC4198 | ❌ ● | 0/0 | Usage of OIDC login_hint | login_hint not handled at OIDC auth |
| MSC4197 | ✅ ● | 100/100 | Copy-Paste Hints | event content field; passthrough |
| MSC4196 | ❌ ● | 0/0 | MatrixRTC voice and video calling application m.call | m.call MatrixRTC slots; no m.rtc.member or m.call.intent handling |
| MSC4195 | ❌ ◐ | 20/20 | MatrixRTC Transport using LiveKit Backend | livekit advertised in /rtc/transports; JWT and delayed events out of scope |
| MSC4194 | ❌ ● | 0/0 | Batch redaction of events by sender within a room (including soft failed events) | POST /rooms/{}/redact/user/{} not wired |
| MSC4193 | ✅ ● | 100/100 | Spoilers on Media | event content field; passthrough; nothing for HS to do |
| MSC4188 | ❌ ● | 0/0 | Handling HTTP 410 Gone Status in Matrix Server Discovery | 410 Gone not specially handled in well-known resolver |
| MSC4186 | ✅ ● | 90/90 | Simplified Sliding Sync | sync v5 implementation routed at simplified_msc3575 path |
| MSC4185 | ❌ ● | 0/0 | Event Visibility API | no can_user_see_event endpoint |
| MSC4184 | ❌ ● | 0/0 | Dynamic Notification Suppression | no m.push_rules_executed field on events |
| MSC4177 | ❌ ● | 0/0 | Add upload location hints proposal | no m.upload.locations or location query param |
| MSC4176 | ❌ ● | 0/0 | Translatable Errors | no localized error messages map |
| MSC4174 | ❌ ● | 0/0 | Web push | no webpush pusher kind or VAPID |
| MSC4173 | ❌ ● | 0/0 | test pusher | no /pushers/push test endpoint |
| MSC4171 | ❌ ● | 0/0 | Service members | no service members handling in heroes |
| MSC4168 | 🟨 ● | 60/60 | Update m.space.* state on room upgrade | src/api/client/room/upgrade.rs:447; copies m.space.parent always plus m.space… |
| MSC4167 | ❌ ● | 0/0 | Copy bans on room upgrade | bans not copied during room upgrade |
| MSC4166 | ✅ ● | 100/100 | Specify /turnServer response when no TURN servers are available | turnServer returns 404 M_NOT_FOUND when no TURN URIs configured |
| MSC4165 | ✅ ● | 100/100 | Remove own power level on deactivation | power level entry removed for self on deactivation |
| MSC4164 | ✅ ● | 100/100 | Leave all rooms on deactivation | deactivation leaves all joined/invited/knocked rooms |
| MSC4162 | ❌ ◐ | 10/10 | One-Time Key Reset Endpoint | no /keys/reset; claim ordering is implicit via key prefix iter |
| MSC4158 | ✅ ◐ | 80/100 | MatrixRTC focus information in .well-known | rtc_foci exposed in .well-known/matrix/client |
| MSC4155 | ❌ ● | 0/0 | Invite filtering | no m.invite_permission_config handling |
| MSC4154 | ✅ ● | 100/100 | Request max body size | max_request_size default 24MB, M_TOO_LARGE returns 413 |
| MSC4152 | ❌ ● | 0/0 | Room labeling and filtering | room labels and /rooms/{roomId}/labels not implemented |
| MSC4149 | 🟨 ◐ | 80/80 | Update CSP Directives for Media Repository | global CSP aligns with MSC; missing font-src and script-src ‘none’ |
| MSC4148 | ❌ ● | 0/0 | Permitting HTTP(S) URLs for SSO IdP icons | SSO IdP icon limited to mxc URIs in config; HTTP(S) not allowed |
| MSC4145 | ❌ ● | 0/0 | Simple verified accounts | m.verified profile field and endpoint not implemented |
| MSC4143 | ✅ ◐ | 80/80 | MatrixRTC | GET rtc/transports routed; only HS-side requirement of the MSC |
| MSC4141 | ❌ ● | 0/0 | Time based notification filtering | time_and_day push rule condition not supported |
| MSC4140 | ❌ ● | 0/0 | Cancellable delayed events | delayed events endpoints not implemented despite Ruma types |
| MSC4136 | ❌ ● | 0/0 | Shared retry hints between servers | retry_hints in /send_join response not implemented |
| MSC4128 | ✅ ● | 100/100 | Error on invalid auth where it is optional | invalid token returns error even on optional auth endpoints |
| MSC4127 | ❌ ● | 0/0 | Removal of query string auth | removal of query string auth not implemented; still accepted |
| MSC4125 | ✅ ● | 90/100 | Specify servers to join via for federated invites | federation invite via field used both inbound and outbound |
| MSC4121 | ✅ ● | 100/100 | m.role.moderator /.well-known/matrix/support role. | m.role.moderator served via Ruma ContactRole alias and config |
| MSC4120 | ❌ ● | 0/0 | Allow HEAD on /download | HEAD on /download not wired; routes mounted via Ruma metadata GET only |
| MSC4117 | ❌ ● | 0/0 | Reinstating Events (Reversible Redactions) | m.room.reinstate (reversible redactions) not implemented |
| MSC4110 | ❌ ● | 0/0 | Fewer Features | m.room.event_features state event has no special server handling |
| MSC4109 | ❌ ● | 0/0 | Appservices & soft-failed events | appservice v2/transactions endpoint with soft-failed events absent |
| MSC4108 | ❌ ◐ | 0/0 | Mechanism to allow OAuth 2.0 API sign in and E2EE set up via QR code | auth_metadata route present; rendezvous and device grant absent |
| MSC4107 | ❌ ● | 0/0 | Feature-focused versioning | features key on /versions not added |
| MSC4106 | ❌ ● | 0/0 | Join as Muted | join-as-muted default_membership not implemented |
| MSC4104 | ❌ ● | 0/0 | Auth Lock: Soft-failure-be-gone! | m.auth_lock event and auth-rule not implemented |
| MSC4103 | ❌ ◐ | 0/0 | Make threaded read receipts opt-in in /sync | threaded_read_receipts sync filter not implemented |
| MSC4102 | ❌ ◐ | 0/0 | Clarifying precedence in threaded and unthreaded read receipts in EDUs | unthreaded-takes-precedence aggregation rule not enforced |
| MSC4101 | ❌ ● | 0/0 | Hashes for unencrypted media | hashes field on unencrypted media info not consumed by server |
| MSC4100 | ❌ ● | 0/0 | Scoped signing keys | scoped signing keys / X-Matrix-Scoped not implemented |
| MSC4097 | ❌ ● | 0/0 | Interactions between media redirection and authentication | media redirect symmetric encryption not implemented |
| MSC4096 | ❌ ● | 0/0 | Proposal to make forceTurn option configurable server-side | forceTurn not advertised in well-known |
| MSC4095 | ❌ ◐ | 10/10 | Bundled URL previews | Ruma type-defs enabled; server is content-agnostic for events |
| MSC4094 | ❌ ● | 0/0 | Sync Server and Client Times with endpoint | GET /_matrix/client/v3/get_server_now endpoint missing |
| MSC4089 | ❌ ● | 0/0 | Delivery Receipts | m.delivery receipts not implemented |
| MSC4086 | ❌ ● | 0/0 | Event media reference counting | event-media reference counting not implemented |
| MSC4084 | ❌ ● | 0/0 | Improving security of MSC2244 | v4 send endpoint with UIA for redactions not implemented |
| MSC4083 | ❌ ● | 0/0 | Delta-compressed E2EE file transfers | delta-compressed media transfers not implemented |
| MSC4081 | ❌ ● | 0/0 | Eagerly sharing fallback keys with federated servers | eager fallback key sharing not implemented |
| MSC4080 | ❌ ● | 0/0 | Cryptographic Identities (Client-Owned Identities) | cryptographic identities/send_pdus endpoint not implemented |
| MSC4079 | ❌ ● | 0/0 | Server-Defined Client Landing Pages | landing_page in well-known not implemented |
| MSC4078 | ❌ ● | 0/0 | Registering pushers against push notification services should forward back fa… | upstream_errcode/upstream_error not surfaced from /pushers/set |
| MSC4076 | 🟨 ● | 60/100 | Let E2EE clients calculate app badge counts themselves (disable_badge_count) | disable_badge_count honored when sending push notifications |
| MSC4075 | ❌ ● | 0/0 | MatrixRTC Notification Event (call ringing) | m.rtc.notification push rule and event handling absent |
| MSC4074 | ❌ ● | 0/0 | Server side annotation aggregation | server-side annotation aggregation not implemented |
| MSC4072 | ❌ ● | 0/0 | Handling devices with no one-time keys in /keys/claim | Missing/exhausted devices are filtered out, not returned as empty objects. |
| MSC4071 | ❌ ● | 0/0 | Pagination Token Headers | No X-Matrix-Pagination-* header handling. |
| MSC4069 | ❌ ● | 0/0 | Inhibit profile propagation | No ?propagate query parameter on profile endpoints. |
| MSC4060 | ❌ ● | 0/0 | Accept room rules before speaking | No m.room.rules state event or acceptance gating. |
| MSC4059 | ❌ ● | 0/0 | Mutable event content | No mutable-event EDU or hashes-omitted detection. |
| MSC4058 | ❌ ● | 0/0 | Additive Events | No m.additive EDU or unsigned.m.additive metadata pipeline. |
| MSC4057 | ❌ ● | 0/0 | Static Room Aliases | No .well-known/matrix/rooms lookup before federation directory. |
| MSC4056 | ❌ ● | 0/0 | Role-Based Access Control (mk II) | No m.role / m.role_map RBAC support. |
| MSC4053 | ❌ ◐ | 0/0 | Extensible Events - Mentions mixin | No mixin push rules with room_version_supports condition. |
| MSC4051 | ✅ ◐ | 80/80 | Using the create event as the room ID | V12 RoomVersionRules.room_create_event_id_as_room_id dispatched. |
| MSC4049 | ❌ ● | 0/0 | Sending events as a server or room | No room version permitting non-user-ID senders. |
| MSC4048 | ❌ ● | 0/0 | Authenticated key backup | No m.backup.v2.curve25519-aes-sha2 algorithm or backup_mac handling. |
| MSC4047 | ❌ ● | 0/0 | Send Keys | No m.room.send_key state event or send-key auth path. |
| MSC4046 | ❌ ● | 0/0 | Make & send PDU endpoints | None of the four make_pdu/send_pdu endpoints implemented. |
| MSC4045 | ❌ ● | 0/0 | Deprecating the use of IP addresses in server names | No room version banning IP-literal server names. |
| MSC4044 | ❌ ● | 0/0 | Enforcing user ID grammar in rooms | No room version enforcing strict user ID grammar. |
| MSC4043 | ❌ ● | 0/0 | Presence Override API | No /presence/{userId}/override endpoint. |
| MSC4042 | ❌ ● | 0/0 | Disabled Presence State | No ‘disabled’ presence state. |
| MSC4038 | ❌ ● | 0/0 | Key backup for MLS | No MLS or m.dmls_backup.v1.aes-hmac-sha2 backup algorithm support. |
| MSC4037 | 🟨 ○ | ?/40 | Thread root is not in the thread | Receipts allowed for thread roots; spec wording is mostly client-facing. |
| MSC4034 | ❌ ● | 0/0 | Media limits | No /usage endpoint and no m.storage.* fields in /config. |
| MSC4033 | ❌ ● | 0/0 | Explicit ordering of events for receipts | No order field on events or receipts. |
| MSC4031 | ❌ ● | 0/0 | Pre-generating invites and room invite codes | pre-generated invites and m.room.invite state event not implemented |
| MSC4029 | 🟨 ◐ | 40/50 | Fixing X-Matrix request authentication | X-Matrix verification covers basics; canonicalization rules not fully specified |
| MSC4028 | ❌ ● | 0/0 | Push all encrypted events except for muted rooms | .m.rule.encrypted_event server-default override rule absent |
| MSC4023 | ❌ ● | 0/0 | Thread ID for second-order relation | unsigned.thread_id not added to events |
| MSC4021 | ❌ ● | 0/0 | Archive client controls | m.room.archive_controls not relayed in /publicRooms |
| MSC4020 | ❌ ● | 0/0 | Room model configuration | m.room.create model object flagging not supported |
| MSC4019 | ❌ ● | 0/0 | Encrypted event relationships | m.room.relationship_encryption flag not handled by server |
| MSC4014 | ❌ ● | 0/0 | Pseudonymous Identities | pseudonymous identities (sender_key, mxid_mapping) not implemented |
| MSC4011 | ❌ ● | 0/0 | Thumbnail media negotiation | thumbnail Accept header negotiation not implemented |
| MSC4005 | ❌ ◐ | 0/0 | Explicit read receipts for sent events | Server does not auto-generate read receipt on send |
| MSC4001 | ❌ ● | 0/0 | Return start of room state at context endpoint | context returns state at LAST event, MSC asks for state at FIRST |
| MSC4000 | ❌ ● | 0/0 | Forwards fill (/backfill forwards) | forwards_fill federation endpoint not implemented |
| MSC3999 | ❌ ● | 0/0 | Add causal parameter to /timestamp_to_event | timestamp_to_event causal event_id parameter not supported |
| MSC3998 | ❌ ● | 0/0 | Add timestamp massaging to /join and /knock | join/knock ts query param not honored |
| MSC3997 | ❌ ● | 0/0 | Add timestamp massaging to /createRoom | createRoom ts query param not honored (always timestamp: None) |
| MSC3996 | ❌ ● | 0/0 | Encrypted mentions-only rooms | m.has_mentions cleartext flag and is_encrypted_mention rule not present |
| MSC3995 | ❌ ● | 0/0 | Linearized Matrix | Linearized Matrix hub/participant architecture not implemented |
| MSC3994 | ❌ ● | 0/0 | Display why an event caused a notification | rule_kind/rule_id not added to /notifications |
| MSC3993 | ❌ ● | 0/0 | Room takeover | room takeover variants not implemented |
| MSC3991 | ❌ ● | 0/0 | Power level up! Taking the room to new heights | raise own power level above max not allowed |
| MSC3985 | ❌ ● | 0/0 | Break-out rooms | m.breakout state event not handled |
| MSC3984 | ❌ ● | 0/0 | Sending key queries to appservices | key query proxy to appservice not implemented |
| MSC3983 | ❌ ● | 0/0 | Sending One-Time Key (OTK) claims to appservices | OTK claim proxy to appservice not implemented |
| MSC3982 | ❌ ● | 0/0 | Limit maximum number of events sent to an AS | no 100-event cap on appservice transactions |
| MSC3971 | ❌ ● | 0/0 | Sharing image packs | image pack sharing/links not implemented |
| MSC3964 | ❌ ● | 0/0 | Notifications for room tags | room_tag push condition not implemented |
| MSC3963 | ❌ ● | 0/0 | Oblivious Matrix over HTTPS | Oblivious MoH endpoints absent |
| MSC3961 | ✅ ● | 90/100 | Sliding Sync Extension: Typing Notifications | sliding sync typing extension implemented |
| MSC3960 | ✅ ● | 90/100 | Sliding Sync Extension: Receipts | sliding sync receipts extension implemented |
| MSC3959 | ✅ ● | 90/100 | Sliding Sync Extension: Account Data | sliding sync account_data extension implemented |
| MSC3955 | ❌ ● | 0/0 | Extensible Events - Automated event mixin (notices) | m.automated mixin for extensible events not implemented |
| MSC3954 | ❌ ● | 0/0 | Extensible Events - Text Emotes | Extensible m.emote event type not specifically handled. |
| MSC3947 | ❌ ● | 0/0 | Allow Clients to Request Searching the User Directory Constrained to Only Hom… | exclude_sources parameter on user_directory/search not implemented. |
| MSC3946 | ❌ ● | 0/0 | Dynamic room predecessor | m.room.predecessor state event not handled. |
| MSC3944 | ❌ ● | 0/0 | Dropping stale send-to-device messages | Stale-to-device cancellation/dedup logic not implemented. |
| MSC3934 | ❌ ● | 0/0 | Bulk push rules change endpoint | PUT /pushrules_bulk/…/actions and /enabled endpoints not implemented. |
| MSC3933 | ❌ ● | 0/0 | Core push rules for Extensible Events | Extensible-event default underride push rules not added. |
| MSC3932 | ❌ ● | 0/0 | Extensible events room version push rule feature flag | Extensible-event room version push rule gating not enabled. |
| MSC3931 | ❌ ● | 0/0 | Push rule condition for room version features | room_version_supports push condition not enabled in tuwunel. |
| MSC3927 | ❌ ● | 0/0 | Extensible Events - Audio | Extensible m.audio event type not specifically dispatched. |
| MSC3926 | ❌ ● | 0/0 | Disable server-default notifications for bot users by default | enable_predefined_push_rules registration body field not implemented. |
| MSC3922 | ❌ ● | 0/0 | Removing SRV records from homeserver discovery | SRV record discovery still active; would need code removal. |
| MSC3917 | ❌ ● | 0/0 | Cryptographically Constrained Room Membership | Cryptographic membership (RRK / RSK / signed memberships) not implemented. |
| MSC3915 | ❌ ● | 0/0 | Owner power level | PL150 owner role / creator-defaults-to-150 not implemented. |
| MSC3914 | ❌ ● | 0/0 | Matrix native group call push rule | .m.rule.room.call push rule + call_started condition not implemented. |
| MSC3912 | ❌ ● | 0/0 | Redaction of related events | with_rel_types / with_relations on /redact not implemented. |
| MSC3911 | ❌ ● | 0/0 | Linking media to events | attach_media query, /media/copy, restrictions block in federation media not p… |
| MSC3909 | ❌ ● | 0/0 | Membership based mutes | Membership-based mutes via new mute/leave-mute states; not implemented. |
| MSC3902 | ❌ ◐ | 20/20 | Faster remote room joins over federation (overview) | sends omit_members but immediately fetches full state |
| MSC3901 | ❌ ◐ | 0/0 | Deleting State | meta-MSC of sub-proposals; obsolete-state cleanup not implemented |
| MSC3896 | ❌ ● | 0/0 | Appservice media | appservice media namespace not implemented |
| MSC3895 | ❌ ● | 0/0 | Federation API Behaviour of Partial-State Resident Servers | M_UNABLE_DUE_TO_PARTIAL_STATE error code not implemented |
| MSC3890 | 🟨 ◐ | 0/? | Remotely silence local notifications | complement: 0p/2f |
| MSC3885 | 🟨 ● | 70/80 | Sliding Sync Extension: To-Device | to_device extension uses its own opaque since token in v5 sync |
| MSC3884 | ✅ ● | 90/100 | Sliding Sync Extension: E2EE | sliding sync e2ee extension implemented |
| MSC3883 | ❌ ● | 0/0 | Fundamental state changes | draft proposal, no concrete API; would require new room version |
| MSC3881 | ❌ ● | 0/0 | Remotely toggling push notifications for another client | pusher enabled and device_id fields not exposed |
| MSC3874 | 🟨 ◐ | 0/? | MSC3874 Loading Messages excluding Threads | complement: 0p/1f |
| MSC3872 | ❌ ◐ | 0/0 | Order of rooms in Spaces | manual room ordering in spaces; vague proposal, no API defined |
| MSC3871 | 🟨 ● | 50/? | Gappy timeline | complement: 3p/3f |
| MSC3870 | ❌ ● | 0/0 | Async media upload extension: upload to URL | upload_url field and /complete endpoint not implemented |
| MSC3866 | ❌ ● | 0/0 | M_USER_AWAITING_APPROVAL error code | M_USER_AWAITING_APPROVAL error code not implemented |
| MSC3865 | ✅ ● | 100/100 | User-given attributes for users | client-side; uses generic account_data endpoints already implemented |
| MSC3864 | ✅ ● | 100/100 | User-given attributes for rooms | client-side; uses generic account_data endpoints already implemented |
| MSC3862 | ❌ ● | 0/0 | event_match (almost) anything | event_match only matches strings; non-string primitives not converted |
| MSC3857 | ❌ ● | 0/0 | Welcome messages/screening | no m.room.welcome state event handling |
| MSC3852 | ❌ ● | 0/0 | Expose user agent information on Device | last_seen_user_agent not exposed on Device |
| MSC3851 | ❌ ● | 0/0 | Allow custom room presets when creating a room | only standard RoomPreset variants accepted; no custom string presets |
| MSC3849 | ❌ ● | 0/0 | Observations and Reinforcement | no observation/reinforcement event handling |
| MSC3848 | ❌ ● | 0/0 | Introduce errcodes for specific event sending failures. | no M_INSUFFICIENT_POWER/M_NOT_JOINED/M_ALREADY_JOINED errcodes emitted |
| MSC3847 | ❌ ● | 0/0 | Ignoring invites with policy rooms | no policy room handling for m.policies account data |
| MSC3845 | ❌ ● | 0/0 | Draft: Expanding policy rooms to reputation | no m.opinion recommendation handling |
| MSC3843 | ❌ ● | 0/0 | Reporting content over federation | federation /rooms/{}/report/{} endpoint not implemented |
| MSC3840 | ❌ ◐ | 0/0 | Ignore invites | client-side ignored invites account data; no server behavior required |
| MSC3837 | ❌ ● | 0/0 | Cascading profile tags for push rules | no profile_tags array; only single profile_tag handled |
| MSC3834 | ❌ ● | 0/0 | Opportunistic user key pinning (TOFU) | TOFU signing key is client-side; no server hooks |
| MSC3825 | ❌ ◐ | 0/0 | Obvious relation fallback location | is_falling_back location handled by Ruma types passively |
| MSC3814 | ✅ ● | 80/90 | Dehydrated devices with SSSS | dehydrated devices SSSS routes wired with put/get/delete and events pagination |
| MSC3779 | ❌ ● | 0/0 | “Owned” state events | owned state events require new room version |
| MSC3772 | ❌ ● | 0/0 | Push rule for mutually related events | relation_match push condition not implemented |
| MSC3767 | ❌ ● | 0/0 | Time based notification filtering | time_and_day push condition not present |
| MSC3761 | ❌ ● | 0/0 | State event change control | m.event.acl ACL events for state not implemented |
| MSC3760 | ❌ ● | 0/0 | State sub-keys | state_subkey requires new room version; not present |
| MSC3759 | ❌ ● | 0/0 | Leave event metadata for deactivated users | deactivation leaves omit m.deactivated metadata |
| MSC3757 | 🟨 ◐ | 0/? | Restricting who can overwrite a state event. | [→ MSC4354] complement: 0p/1f |
| MSC3744 | ❌ ● | 0/0 | Support for flexible authentication | no flexible-auth /register or /account/authenticator endpoints |
| MSC3741 | ❌ ● | 0/0 | Revealing the useful login flows to clients after a soft logout | login does not return per-user flows for soft-logout tokens |
| MSC3726 | ❌ ● | 0/0 | Safer Password-based Authentication with BS-SPEKE | open MSC; no BS-SPEKE login/register/password flows |
| MSC3723 | ❌ ● | 0/0 | Federation /versions | open MSC; no /_matrix/federation/versions endpoint |
| MSC3720 | ❌ ● | 0/0 | Account status endpoint | branch MSC; no /account_status endpoints (CS or federation) |
| MSC3713 | ❌ ● | 0/0 | Alleviating ACL exhaustion with ACL Slots | open MSC; no ACL slot state-key handling |
| MSC3682 | ❌ ● | 0/0 | Sending Account Data to Application Services | AS transactions do not include account_data field |
| MSC3673 | ❌ ● | 0/0 | Encrypting ephemeral data units | branch MSC; no encrypted EDU envelope support |
| MSC3672 | ❌ ● | 0/0 | Sharing ephemeral streams of location data | branch MSC; no m.beacon EDU support or location streaming |
| MSC3664 | ❌ ● | 0/0 | Pushrules for relations | no related_event_match push rule condition implemented |
| MSC3647 | ❌ ● | 0/0 | Bring Your Own Bridge - Decentralising Bridges | WIP bridge negotiation; no spec-level details, no server impl |
| MSC3618 | ❌ ◐ | 0/0 | Simplify federation /send response | branch MSC; tuwunel returns full pdus map per current spec |
| MSC3613 | ❌ ● | 0/0 | Combinatorial join rules | branch MSC; no combinatorial join_rules array logic in tuwunel |
| MSC3593 | ❌ ● | 0/0 | Safety Controls through a generic Administration API | none of the proposed /admin/* endpoints exist; tuwunel uses admin room |
| MSC3585 | ✅ ● | 100/100 | Allow the base event to be omitted from /federation/v1/event_auth response | event_auth handler omits the requested event itself per MSC |
| MSC3575 | ✅ ◐ | ?/? | Sliding Sync (aka Sync v3) | [→ MSC4186] src/api/client/sync/v5.rs:62 |
| MSC3574 | ❌ ● | 0/0 | Marking up resources | no m.markup.resource or annotation handling |
| MSC3572 | ❌ ◐ | 0/0 | Relation aggregation cleanup | no relations rename; m.relations only |
| MSC3571 | ❌ ● | 0/0 | Aggregation pagination | no /aggregations endpoint; no aggregation pagination |
| MSC3570 | ❌ ◐ | 0/0 | Relation history visibility changes | no special history visibility for relations; new room version needed |
| MSC3554 | ❌ ● | 0/0 | Extensible Events - Translatable Messages | no lang field handling; ruma feature not enabled |
| MSC3553 | ❌ ● | 0/0 | Extensible Events - Videos | unstable-msc3553 not enabled in ruma features |
| MSC3552 | ❌ ● | 0/0 | Extensible Events - Images and Stickers | unstable-msc3552 not enabled in ruma features |
| MSC3551 | ❌ ● | 0/0 | Extensible Events - Files | unstable-msc3551 not enabled; no extensible m.file event |
| MSC3547 | ❌ ● | 0/0 | Allow appservice bot user to read any rooms the appservice is part of | appservice still must masquerade or be a member |
| MSC3523 | ❌ ● | 0/0 | Timeboxed/ranged relations endpoint | no from_target/to_target query params on /relations |
| MSC3489 | ❌ ◐ | 20/20 | m.beacon: Sharing streams of location data with history | unstable-msc3489 ruma feature on; no specific beacon logic |
| MSC3488 | ❌ ◐ | 10/10 | m.location: Extending events with location data | location event types pass through; no m.tile_server in well-known |
| MSC3480 | ❌ ◐ | 10/20 | Make device names private | allow_device_name_federation config gates device name exposure |
| MSC3469 | 🟨 ○ | ?/50 | Mandate HTTP Range on Content Repository Endpoints | depends on object_store / hyper response writer for ranges |
| MSC3468 | ❌ ● | 0/0 | MXC to Hashes | no MXC-to-hash endpoints; no /clone or /hash routes |
| MSC3417 | ✅ ● | 100/100 | Call room room type | creation_content type=m.call passes through createRoom |
| MSC3414 | ❌ ● | 0/0 | Encrypted state events | no encrypted state event handling or encrypted_state in publicRooms |
| MSC3401 | ❌ ● | 0/10 | Native Group VoIP signalling | only default PL for m.call/m.call.member; no to-device signaling |
| MSC3395 | ❌ ● | 0/0 | Synthetic Appservice Events | no synthetic appservice events emitted on register/login/logout |
| MSC3394 | ❌ ● | 0/0 | New auth rule that only allows someone to post a message in relation to anoth… | no auth rule restricting top-level vs threaded messages |
| MSC3389 | ❌ ● | 0/0 | Redaction changes for events with a relation | no m.relates_to preservation in redactions |
| MSC3386 | ❌ ● | 0/0 | Unified Join Rules | no unified allow_join/allow_knock; no new room version |
| MSC3385 | 🟨 ◐ | 30/40 | Bulk small improvements to room upgrades | upgrade copies fixed list of state, not all m.* state nor account_data |
| MSC3368 | ❌ ● | 0/0 | Message Content Tags | no message-content tag awareness |
| MSC3361 | ❌ ● | 0/0 | Opportunistic Direct Push | no direct pusher kind or notifications in sync |
| MSC3360 | ❌ ● | 0/0 | Server Status | no /server/status endpoint or m.server.status event |
| MSC3359 | ❌ ● | 0/0 | Delayed Push | no jitter pusher field; not advertised in versions |
| MSC3356 | ❌ ● | 0/0 | Add additional OpenID user info fields | openid userinfo returns only sub |
| MSC3338 | ❌ ● | 0/0 | Adding iframe specifics to preview json | url preview has no iframe/oEmbed support |
| MSC3325 | ❌ ● | 0/0 | Upgrading invite-only rooms | upgrade does not switch invite-only rooms to restricted |
| MSC3309 | ❌ ● | 0/0 | Room Counters | no m.room.counter event handling |
| MSC3306 | ❌ ● | 0/0 | How to count unread messages | notification_count uses push-rule Notify actions, not MSC3306 algo |
| MSC3277 | ❌ ● | 0/0 | Scheduled messages | no scheduled-message at= query param support |
| MSC3269 | ❌ ● | 0/0 | An error code for busy servers | no M_SERVER_BUSY error code |
| MSC3262 | ❌ ● | 0/0 | aPAKE authentication | SRP6a aPAKE login/registration not implemented |
| MSC3219 | ❌ ● | 0/0 | Space Flair | space flair events and member flag not implemented |
| MSC3217 | ❌ ● | 0/0 | Clientside hints for a soft kick | m.softkick hint on member event not implemented |
| MSC3216 | ❌ ● | 0/0 | Synchronized access control for Spaces | space-level synchronized PL replication absent |
| MSC3215 | ❌ ● | 0/0 | Aristotle - Moderation in all things | decentralized moderation room scheme not implemented |
| MSC3214 | ✅ ◐ | 90/100 | Allow overriding m.room.power_levels using initial_state | initial_state PL effectively replaces default via later append |
| MSC3202 | 🟨 ● | 20/20 | Encrypted Appservices | device_id masquerading present; AS txn extensions missing |
| MSC3192 | ❌ ● | 0/0 | Batch state endpoint | batch_state endpoint not implemented |
| MSC3189 | ❌ ● | 0/0 | Per-room/per-space profiles | per-room/space scoped profile API not implemented |
| MSC3174 | ❌ ● | 0/0 | An error code for spam rejections | M_ANTISPAM_REJECTION error code not used |
| MSC3144 | ❌ ● | 0/0 | Allow Widgets By Default in Private Rooms | private_chat preset does not lower widgets PL |
| MSC3105 | ❌ ● | 0/0 | Previewing user-interactive flows | OPTIONS preflight for UIA flows not implemented |
| MSC3089 | ❌ ● | 0/0 | File trees | client-only data trees on m.space; no server change required |
| MSC3088 | ❌ ● | 0/0 | Room subtyping | client-only m.room.purpose state event; no server change required |
| MSC3079 | ❌ ● | 0/0 | Low Bandwidth Client-Server API | branch; no CoAP/CBOR/DTLS support |
| MSC3060 | ❌ ● | 0/0 | Room labels | branch; m.room.labels not surfaced in publicRooms |
| MSC3051 | ❌ ◐ | 0/0 | A scalable relation format | open; m.relations array not handled |
| MSC3038 | ❌ ● | 0/0 | Typed Typing Notifications | branch; no events field on typing |
| MSC3032 | ❌ ◐ | 20/20 | Thoughts on updating presence | effective presence; busy supported, profile-as-rooms absent |
| MSC3026 | ✅ ● | 100/100 | busy presence state | PresenceState::Busy and msc3026.busy_presence flag |
| MSC3020 | ❌ ◐ | 0/0 | Support for private federation networks | branch; same proposal as MSC3018, not implemented |
| MSC3018 | ❌ ◐ | 0/0 | Support for private federation networks | branch; no m.networks capability or network query |
| MSC3014 | ❌ ● | 0/0 | HTTP Pushers for the full event with extra rooms information | open; no full_event_with_rooms pusher format |
| MSC3012 | ❌ ● | 0/0 | Post-registration terms of service API | branched; no /terms endpoint or m.terms account data |
| MSC2970 | 🟨 ◐ | 40/50 | Remove pusher path requirement | path/scheme constraints relaxed; lacks fragment/userinfo/8000-char checks |
| MSC2962 | ❌ ● | 0/0 | Managing power levels via Spaces | no auto_users or m.room.power_level_mappings handling |
| MSC2961 | ❌ ◐ | 0/10 | External Signatures | endpoint accepts arbitrary signature keys; object form discarded |
| MSC2943 | ❌ ● | 0/0 | Return an event ID for membership endpoints | membership endpoint responses lack event_id |
| MSC2938 | ❌ ● | 0/10 | Report content to moderators | target field and room_moderators routing not implemented |
| MSC2923 | ❌ ◐ | 0/0 | Matrix to Matrix connections | speculative idea-stage; no concrete API |
| MSC2895 | ❌ ● | 0/0 | Improving the way membership lists are queried | no /rooms endpoint nor ?membership query on /members |
| MSC2883 | ❌ ● | 0/0 | [WIP] Matrix-flavoured MLS | WIP MLS; no DMLS support |
| MSC2882 | ❌ ◐ | 0/0 | [WIP] Tempered Transitive Trust | WIP; new public_user_signing key, m.device.signature EDU not implemented |
| MSC2855 | ❌ ◐ | 0/0 | Server-Initiated Client Clear-Cache & Reload | no clear-cache signal mechanism |
| MSC2848 | ❌ ● | 0/10 | Globally unique event IDs | only legacy GET /event/:eventId; new room-scoped path absent |
| MSC2846 | ❌ ● | 0/0 | Decentralizing media through CIDs | open; CID-based MXC URLs not implemented |
| MSC2845 | ❌ ◐ | 0/5 | Thirdparty Lookup API for Telephone Numbers | src/api/client/thirdparty.rs returns empty protocols TODO |
| MSC2836 | ❌ ● | 0/0 | Threading | advertises org.matrix.msc2836 in /versions but no event_relationships |
| MSC2828 | ❌ ◐ | 0/0 | Proposal to restrict allowed user IDs over federation | no extended_user_id_char auth rule restriction |
| MSC2821 | ❌ ● | 0/0 | Test Pusher | POST /pushers/push test endpoint not implemented |
| MSC2815 | ✅ ◐ | 90/100 | Proposal to allow room moderators to view redacted event content | include_unredacted_content honored; admin or redact PL gates access |
| MSC2812 | ❌ ● | 0/0 | Role-based power structures | role-based power proposal still draft; no m.role events |
| MSC2802 | ❌ ● | 0/0 | Full Room Abstraction | open meta proposal to redesign spec; not implementable as-is |
| MSC2787 | ❌ ● | 0/0 | Portable Identities | no UPK/UDK/attestation infrastructure |
| MSC2785 | ❌ ● | 0/0 | Event notification attributes and actions | no notification_attribute_data or notifications_profile endpoints |
| MSC2782 | 🟨 ◐ | 30/50 | Pushers with the full event content | src/service/pusher/send.rs sends full event when format != event_id_only |
| MSC2772 | ❌ ◐ | 0/0 | Notifications for Jitsi Calls | no .m.jitsi default underride push rules |
| MSC2757 | ❌ ● | 0/0 | Sign Events | No event_signing key type; no client signature plumbing |
| MSC2755 | ❌ ● | 0/0 | Lazy load rooms | No room_limit_by_complexity filter handling |
| MSC2753 | ❌ ● | 0/0 | Peeking via Sync (Take 2) | No /peek or /unpeek; no peek section in sync |
| MSC2749 | ❌ ● | 0/0 | Per-user E2EE on/off setting | No m.encryption capability; no force/preference logic |
| MSC2730 | ❌ ● | 0/0 | Verifiable forwarded events | No /forward/{targetRoomId}; no signature validation |
| MSC2716 | ❌ ● | 0/0 | Incrementally importing history into existing rooms | No /batch_send; no m.room.insertion/batch/marker handling |
| MSC2706 | ❌ ● | 0/0 | IPFS as a media repository | No IPFS support; no m.ipfs capability |
| MSC2704 | ✅ ◐ | 100/100 | Handling duplicate media on /upload + clarifying the origin of an MXC URI | Fresh MXC per upload; no dedup |
| MSC2703 | ✅ ● | 100/100 | Media ID grammar | 32-char alphanumeric media IDs; opaque |
| MSC2700 | 🟨 ◐ | 50/50 | Thumbnail requirements for the media repo | image crate handles png/jpeg/gif; no svg/video |
| MSC2695 | 🟨 ● | 40/40 | Get event by ID over federation | Federation /event exists; no client /events/{eventId} revival |
| MSC2673 | ❌ ● | 0/0 | Notification Levels | No notification_levels concept; push rules used |
| MSC2654 | ❌ ● | 0/0 | Unread counts | No unread_count in sync; no msc2654 markers |
| MSC2638 | ❌ ● | 0/0 | Ability for clients to request homeservers to resync device lists | No /devices/refresh endpoint; no msc2638 marker in src |
| MSC2625 | ❌ ◐ | 0/0 | Add mark_unread push rule action | No mark_unread action; sync exposes only highlight/notification counts |
| MSC2596 | ❌ ◐ | 0/0 | Proposal to always allow rescinding invites | Vendor room version net.maunium.msc2596 not registered; no rescind exception … |
| MSC2513 | ❌ ◐ | 0/10 | Allow clients to specify content for membership events | Membership endpoints accept reason only; no content body param |
| MSC2499 | 🟨 ◐ | 10/30 | Fixes for Well-known URIs | src/service/resolver/well_known.rs follows redirects; 12288B cap; uses /versions |
| MSC2487 | ❌ ◐ | 0/0 | Filtering for Appservices | No filter field on appservice registration |
| MSC2477 | ❌ ◐ | 0/0 | User-defined ephemeral events in rooms | No PUT /rooms/{roomId}/ephemeral/{type}/{txnId} route |
| MSC2448 | 🟨 ● | 70/80 | Using BlurHash as a Placeholder for Matrix Media | blurhash on profile, federation query, media upload, member events |
| MSC2444 | 🟨 ● | 30/30 | Proposal for implementing peeking over federation (peek API) | world_readable allowed on some federation reads; no /peek subscription API |
| MSC2438 | ❌ ● | 0/10 | Local and Federated User Erasure Requests | deactivate present but no erase param, no fed/AS erase endpoints |
| MSC2437 | ✅ ◐ | 100/100 | Store tagged events in Room Account Data | m.tagged_events stored via existing room account_data routes |
| MSC2391 | ❌ ● | 0/0 | Federation point-queries. | No federation point-query state endpoint |
| MSC2380 | ❌ ● | 0/0 | Matrix Media Information API | No /media/r0/info/{origin}/{media_id} endpoint |
| MSC2379 | ❌ ● | 0/0 | MSC 2379: Add /versions endpoint to Appservice API. | No /_matrix/app/versions probe code |
| MSC2375 | ❌ ◐ | 0/0 | Appservice Invite States | Appservice transactions send raw PDU JSON without invite_room_state injection |
| MSC2370 | ❌ ● | 0/0 | Resolve URL API | No /resolve_url endpoint in source |
| MSC2356 | ❌ ● | 0/0 | Bulk /joined_members endpoint | No POST /joined_members bulk endpoint in src/api |
| MSC2326 | ❌ ● | 0/0 | Label based filtering | No labels/not_labels EventFilter support; no m.label handling |
| MSC2316 | ❌ ● | 0/0 | Federation queries to aid with database recovery | No /_matrix/federation/v1/query/members route |
| MSC2314 | ❌ ● | 0/40 | Backfilling Current State | src/api/server/state.rs:14 requires event_id; no current-state branch |
| MSC2306 | ✅ ◐ | 100/100 | Removing MSISDN password resets | msisdn pw reset endpoint absent; ThreepidDenied on msisdn |
| MSC2301 | ❌ ● | 0/0 | Proposal for an /info endpoint on the CS API | No /info merger of /versions; no branding fields exposed |
| MSC2300 | ❌ ● | 0/0 | Proposal for a /ping endpoint on the CS API | No GET /_matrix/client/r0/ping route |
| MSC2278 | ❌ ◐ | 0/10 | Proposal for deleting content for expired and redacted messages | No DELETE /media client API; only admin-only delete helper |
| MSC2271 | ❌ ● | 0/0 | Proposal for TOTP 2FA | No TOTP endpoints, no m.login.totp UIA stage |
| MSC2261 | ✅ ● | 100/100 | Allow m.room.aliases events to be redacted by room admins | Subsumed by MSC2432/v6 redaction rules |
| MSC2260 | 🟨 ● | 50/50 | Update the auth rules for m.room.aliases events | Subsumed by MSC2432/v6 auth rules; aliases sender-domain check enforced |
| MSC2233 | ❌ ● | 0/0 | Unauthenticated Capabilities API | no /capabilities/server unauthenticated endpoint |
| MSC2228 | ❌ ● | 0/0 | Proposal for self-destructing messages | self_destruct fields not honored |
| MSC2214 | ❌ ● | 0/0 | Joining upgraded private rooms | m.room.previous_member event not implemented |
| MSC2213 | ❌ ● | 0/0 | Rejoinability of private rooms | rejoin_rule field not implemented |
| MSC2212 | ❌ ● | 0/0 | Third party user power levels | third_party_users not present in PL handling or auth rules |
| MSC2199 | ❌ ● | 0/0 | Canonical DMs (server-side middle ground edition) | no m.kind in sync summary; uses legacy m.direct account data |
| MSC2190 | ✅ ◐ | 80/80 | Allow appservice bots to use /sync | appservice token defaults to sender_localpart user |
| MSC2153 | ✅ ● | 100/100 | Add a default push rule to ignore m.reaction events | Ruleset::server_default() includes .m.rule.reaction via Ruma |
| MSC2127 | ❌ ● | 0/0 | Proposal for a federation capabilities API | federation /capabilities and per-room capabilities not present |
| MSC2108 | ❌ ● | 0/0 | Sync over Server Sent Events | no /sync/sse or text/event-stream paths |
| MSC2102 | ❌ ◐ | 0/0 | Enforce Canonical JSON on the wire for the S2S API | no canonical-JSON wire enforcement on inbound S2S |
| MSC2061 | ✅ ● | 100/100 | make the trailing slash on GET /_matrix/key/v2/server/ optional | src/api/router.rs:250 routes both /key/v2/server and /server/{key_id} |
| MSC2000 | ❌ ● | 0/0 | MSC 2000: Proposal for server-side password policies | branch; no /password_policy endpoint or password validation |
| MSC1974 | ❌ ● | 0/0 | Crypto Puzzle Challenge | open; hashcash-style proof-of-work never adopted |
| MSC1973 | ❌ ● | 0/0 | Hash Key User ID | open; speculative scheme never adopted |
| MSC1953 | ✅ ● | 100/100 | Remove prev_content from the essential keys list | ruma redact() does not retain prev_content |
| MSC1943 | ✅ ● | 100/100 | Set v3 to be the default room version | default room version V11 (>= v3) |
| MSC1921 | ❌ ◐ | 0/0 | Cancellation of 3pid validation tokens | 3pid cancelToken endpoints not implemented; 3pid stack stubbed |
| MSC1862 | ❌ ◐ | 20/20 | Presence flag for capabilities API | presence on/off enforced; m.presence not in /capabilities response |
| MSC1818 | ✅ ● | 100/100 | Remove references to presence lists | presence list endpoints absent (compliant by removal) |
| MSC1797 | ❌ ● | 0/0 | Proposal for more granular profile error codes | branch; M_USER_NOT_FOUND/M_PROFILE_* error codes not used |
| MSC1796 | ❌ ◐ | 0/0 | Proposal for improving notifications for E2E encrypted rooms | branch; m.mentions on encrypted events not honored server-side |
| MSC1780 | ❌ ● | 0/0 | Add DIDs and DID names as admin accounts to HS | open; m.did medium not supported in 3pid endpoints |
| MSC1777 | ❌ ● | 0/0 | Proposal for implementing peeking over federation (server pseudousers) | branch; server pseudouser peeking not implemented |
| MSC1776 | ❌ ● | 0/0 | Proposal for implementing peeking via /sync in the CS API | branch; POST /sync with peek not implemented |
| MSC1769 | ❌ ● | 0/0 | Proposal for extensible profiles as rooms | branch; profile-as-rooms not implemented |
| MSC1768 | ❌ ● | 0/0 | Proposal to authenticate with public keys | open; m.login.proof.* not implemented |
| MSC1763 | ❌ ● | ?/0 | Proposal for specifying configurable per-room message retention periods. | no m.room.retention support; /retention/configuration endpoint absent |
| MSC1740 | ❌ ◐ | ?/0 | Using the Accept header to select an encoding | no Accept-based content negotiation; only application/json supported |
| MSC1731 | ❌ ◐ | 0/0 | Mechanism for redirecting to an alternative server during SSO login | branch; homeserver query param on sso loginToken redirect not added |
| MSC1716 | ❌ ● | ?/0 | Open on device API | client-only m.openondevice event type; nothing server-side to implement |
| MSC1714 | ❌ ● | 0/0 | using the TLS private key to sign federation-signing keys | branch/abandoned 2018; no rsa key id, no TLS-cross-signing in src/api/server/… |
| MSC1700 | ✅ ◐ | 80/80 | Improving .well-known discovery of homeservers | well-known client+server discovery served from config |
| MSC1687 | ❌ ● | ?/0 | Proposal for storing an encrypted recovery key on the server to aid recovery … | no PBKDF passphrase backup logic; auth_data passes through opaquely |
| MSC1607 | ❌ ◐ | 0/0 | Proposal for room alias grammar | alias parsing delegated to Ruma RoomAliasId; no NFKC/punycode/blacklist logic |
| MSC1597 | ❌ ◐ | 0/0 | Grammars for identifiers in the Matrix protocol | identifier validation delegated to Ruma; proposal is exploratory |
| MSC1229 | ❌ ◐ | 0/0 | Mitigating abuse of the event depth parameter over federation | legacy 2018 issue tracked via redirect; depth-abuse mitigations not implement… |
| MSC1228 | ❌ ● | ?/0 | Removing MXIDs from events | removing mxids never merged; no user_room_key or pseudo IDs in src |
Closed
Sorted by MSC number, highest first. Out-of-scope rows are listed in the Out of scope section.
| MSC | Status | Correct/Impl | Title | Note |
|---|---|---|---|---|
| MSC4465 | ❌ ● | 0/0 | On-Demand Fetch for Missing Events | GET /event/{eventId} returns M_NOT_FOUND; no federation fallback. |
| MSC4463 | ❌ ◐ | 0/0 | Backfilling Pinned Events | No pinned-events backfill on join or on pinned_events update. |
| MSC4317 | ❌ ● | 0/0 | Signed profile data | signed profile data; no m.signed profile field handling |
| MSC4316 | ❌ ● | 0/0 | External cross-signing signatures with X.509 certificates and (semi-)automate… | X.509 cross-signing; no external signature support |
| MSC4294 | ❌ ● | 0/0 | Ignore and mass ignore invites | no ignored_inviters list, no auto invite cleanup |
| MSC4214 | ❌ ● | 0/0 | Embedding Widgets in Messages | closed MSC; m.widget event/capability not implemented |
| MSC4124 | ❌ ● | 0/0 | Simple Server Authorization | m.server.knock/participation auth events not implemented |
| MSC4123 | ❌ ● | 0/0 | Allow knock -> join transition | new room version with knock to join transition not implemented |
| MSC4113 | ❌ ● | 0/0 | Image hashes in Policy Lists | m.policy.media_hash unknown to server (closed MSC) |
| MSC4098 | ❌ ● | 0/0 | Use the SCIM protocol for provisioning | SCIM user provisioning endpoints absent (closed MSC) |
| MSC4018 | ❌ ● | 0/0 | Reliable call membership | Reliable call membership endpoints (PUT/DELETE) not present |
| MSC3978 | ❌ ● | 0/0 | Deprecate room tagging | room tagging not deprecated; still implemented |
| MSC3975 | ❌ ● | 0/0 | rel_type for Replies | m.reply rel_type not handled |
| MSC3969 | ❌ ● | 0/0 | Size limits | m.room.size_limits state event not enforced |
| MSC3968 | ❌ ● | 0/0 | Poorer features | m.room.event_features state event not enforced |
| MSC3945 | 🟨 ◐ | 50/50 | Private device names | Federation hides device names by default; CSAPI /keys/query still leaks them … |
| MSC3887 | ❌ ◐ | 0/0 | List matching push rules | closed MSC; list-matching in event_match not implemented |
| MSC3859 | ❌ ● | 0/0 | Add well known media domain proposal | no m.media_server in well-known responses |
| MSC3782 | ❌ ● | 0/0 | Matrix public key login spec | m.login.publickey login type not implemented |
| MSC3754 | ❌ ● | 0/0 | Removing profile information | [→ MSC4133?] DELETE profile endpoints not exposed |
| MSC3659 | ❌ ● | 0/0 | Invite Rules | closed MSC; no invite_rules account data dispatch |
| MSC3464 | ❌ ● | 0/0 | Allow Users to Post on Behalf of Other Users | no m.on_behalf_of or m.allows_on_behalf_of handling |
| MSC3429 | ❌ ● | 0/0 | Individual room preview API | no /rooms/{id}/preview endpoint |
| MSC3391 | ✅ ● | 100/100 | API to delete account data | src/api/client/account_data.rs:126; both DELETE routes via Ruma<R>; tombstone… |
| MSC3286 | ❌ ● | 0/0 | Media spoilers | server passes events opaquely; no spoiler-aware code |
| MSC3244 | ❌ ● | 0/10 | Room version capabilities | capabilities lacks room_capabilities knock/restricted info |
| MSC3137 | ✅ ● | 100/100 | Define space room type, subset of MSC1772 | type:m.space in m.room.create accepted; used in directory and spaces |
| MSC3125 | ❌ ● | 0/0 | Limits API — Part 5: per-Instance limits | per-instance limits admin API absent |
| MSC3073 | ❌ ● | 0/0 | Role based access control | closed; rbac/m.role not implemented |
| MSC3053 | ❌ ● | 0/0 | Limits API — Part 2: per-Room limits | closed; no admin/limits endpoints or m.limits.* events |
| MSC3013 | ❌ ● | 0/0 | Encrypted Push | closed; no encrypted-push algorithm support |
| MSC3007 | ❌ ● | 0/0 | Forced insertion and room blocking by self-banning | closed; no insert_member power or /insert endpoint |
| MSC3006 | ❌ ● | 0/0 | Bot Interactions | closed; bot-interaction event types not implemented |
| MSC3005 | ❌ ● | 0/0 | Streaming Federation Events | closed; no streaming federation transport |
| MSC2957 | ❌ ● | 0/0 | Cryptographically Concealed Credentials | PAKE-style login flow; closed; not implemented |
| MSC2912 | ❌ ● | 0/0 | Setting cross-signing keys during registration | no device_signing field accepted by /register |
| MSC2839 | ❌ ◐ | 0/0 | Dynamic User-Interactive Authentication | closed; UIA flows are static in Tuwunel |
| MSC2835 | ❌ ◐ | 0/10 | Add UIA to the /login endpoint | closed; /login does not consume UIA auth dict |
| MSC2773 | ❌ ◐ | 0/0 | Room kinds | closed; no m.kind summary or m.room.kind handling |
| MSC2631 | ✅ ◐ | 80/80 | Add default_payload to PusherData | ruma HttpPusherData flattens custom data; default_payload accepted via passth… |
| MSC2463 | ❌ ◐ | 0/0 | Exclusion of MXIDs in push rules content matching | closed MSC; no MXID exclusion in push rule content matching |
| MSC2416 | ✅ ● | 90/100 | Add m.login.jwt authentication type | m.login.jwt fully wired in session module |
| MSC1998 | ❌ ● | 0/0 | Two-Factor Authentication Providers | closed; TOTP/recovery 2FA never adopted by spec |
| MSC1888 | ✅ ● | 90/100 | Proposal to send EDUs to appservices | [→ MSC2409] appservice receive_ephemeral with EDU push; src/service/sending/s… |
| MSC1497 | ✅ ● | 100/100 | Advertising support of experimental features in the CS API | unstable_features map present in /_matrix/client/versions |
| MSC1425 | ✅ ● | 100/100 | Room Versioning | room versioning fully present; STABLE_ROOM_VERSIONS in core/config |
| MSC1301 | ❌ ◐ | 0/0 | Proposal for improving authorization for the matrix profile API | legacy 2018 issue (closed) tracked via redirect; profile-share-room limit not… |
| MSC1227 | ✅ ● | 80/90 | Proposal for lazy-loading room members to improve initial sync speed and clie… | lazy_load_members supported via filter; service in rooms/lazy_loading |
Out of scope
MSCs marked n/a: out of scope for a homeserver (3PID-only,
identity-server-only, integration-manager-only, widget/client-only,
governance/process, or superseded by another MSC). Listed here for
audit; the Inv column carries each row’s inventory bucket in
place of the (uniformly empty) Correct/Impl cell.
| MSC | Status | Inv | Title | Note |
|---|---|---|---|---|
| MSC4456 | ⬛ ● | open | Harms taxonomy | Pure spec appendix listing harm identifiers |
| MSC4455 | ⬛ ● | open | Catch-all property for spaces | Client-only space catch-all state event; MSC says servers not required |
| MSC4454 | ⬛ ● | open | Deprecating Spoiler Fallback In Media Repository | Client-side spoiler text behavior; no server change |
| MSC4451 | ⬛ ● | open | Deprecate notifications endpoint | Spec-only deprecation; endpoint still served per MSC |
| MSC4444 | ⬛ ● | closed | Malicious PDUs | April Fools joke MSC, status closed |
| MSC4441 | ⬛ ● | open | Encrypted User Profile Annotations via Account Data | Client-side only encrypted account data convention |
| MSC4421 | ⬛ ● | closed | Standardize the spec on US English | spec house-style proposal (en-US); no protocol surface. |
| MSC4415 | ⬛ ● | closed | Make /_matrix/client/v3/admin/whois/{userId} only available to admins | /_matrix/client/v3/admin/whois not implemented at all in Tuwunel. |
| MSC4414 | ⬛ ● | open | Design decision - Errors | design-direction proposal with no technical changes. |
| MSC4412 | ⬛ ● | open | Widget Base PostMessage API | widget postMessage protocol; entirely client/host-widget. |
| MSC4411 | ⬛ ● | open | Widget State Event | widget state event schema only; server stores the state event opaquely. |
| MSC4409 | ⬛ ● | open | Clarify thumbnailing behavior in E2EE | clarifies client thumbnail behavior in E2EE; no server change. |
| MSC4407 | ⬛ ● | open | Sticky Events (Widget API) | widget API for sticky events; no homeserver involvement beyond MSC4354. |
| MSC4405 | ⬛ ● | open | Deprecate the emoji method for SAS verification | deprecates emoji SAS in favor of decimal; client-side method choice. |
| MSC4404 | ⬛ ● | open | Compare emoji by name rather than image | adds accept_languages to to-device verification; client SAS UI guidance. |
| MSC4402 | ⬛ ● | open | Consistent redirects for .well-known-files | [→ MSC2499?] client-side guidance to follow 30x on /.well-known/matrix/client. |
| MSC4397 | ⬛ ● | open | Tags as Spaces | account_data key m.tag_space points at a private space; server is opaque. |
| MSC4392 | ⬛ ● | open | Encrypted reactions and replies | client puts m.relates_to inside encrypted payload; server forwards untouched. |
| MSC4391 | ⬛ ● | open | Simplified in-room bot commands | in-room bot command UI; state and message events forwarded opaquely. |
| MSC4389 | ⬛ ● | open | Image ordering within packs | image pack ordering is account-data; server passes through opaque blobs. |
| MSC4386 | ⬛ ● | open | Automatically sharing secrets after device verification | client-to-client to-device verification protocol; server forwards opaque events. |
| MSC4385 | ⬛ ● | open | Pushing secrets to other devices | Client-side to-device event convention |
| MSC4381 | ⬛ ◐ | merged | Remove plaintext sender key | Removal of plaintext sender_key is client-side; server is opaque |
| MSC4377 | ⬛ ● | open | Clarify Image Pack Ordering | Image pack ordering is client-side account/state data convention |
| MSC4359 | ⬛ ● | open | “Do not Disturb” notification settings | Client-side account data event; no server behavior required |
| MSC4357 | ⬛ ● | open | Live Messages via Event Replacement | Client-only convention reusing m.replace; no server work |
| MSC4356 | ⬛ ● | merged | Recently used emoji | Pure client-side account data convention; no server work |
| MSC4347 | ⬛ ● | open | Emoji verification images | client-side emoji image rendering for SAS verification; not server |
| MSC4313 | ⬛ ● | merged | Require HTML <ol> start Attribute support | client HTML rendering requirement; not applicable to homeserver |
| MSC4302 | ⬛ ● | open | Exchanging FHIR resources via Matrix events | new event type for FHIR, no server logic |
| MSC4301 | ⬛ ● | closed | Event capability negotiation between clients | client-to-client capability negotiation |
| MSC4300 | ⬛ ● | open | Processing status requests & responses | client-to-client status request/response in events |
| MSC4299 | ⬛ ◐ | open | trusted users | foundation MSC; defines account-data only, no concrete server behavior |
| MSC4296 | ⬛ ● | open | Mentions for device IDs | client-side mentions field extension |
| MSC4295 | ⬛ ● | open | Bot bounce limit - a better loop prevention mechanism | bot/client behavior; servers relay events unmodified |
| MSC4292 | ⬛ ● | open | Handling incompatible room versions in clients | [→ MSC4331] |
| MSC4287 | ⬛ ● | merged | Sharing key backup preference between clients | client-side account data for key backup preference |
| MSC4286 | ⬛ ● | open | App store compliant handling of payment links within events | client-side HTML rendering attribute |
| MSC4283 | ⬛ ● | open | Distinction between Ignore and Block | terminology MSC, no implementation surface |
| MSC4281 | ⬛ ● | closed | Mitigating Membership Mistakes, or “Invisible” Cryptography | closed April 1 joke MSC; client-only encryption mode |
| MSC4278 | ⬛ ● | open | Media preview controls | client-side account data preferences |
| MSC4274 | ⬛ ● | open | Inline media galleries via msgtypes | new client msgtype m.gallery, no server logic |
| MSC4273 | ⬛ ● | open | Approve and Disapprove ratings for moderation policies | new event type for moderation tools, no server logic |
| MSC4270 | ⬛ ● | open | Matrix Glossary | glossary/spec doc proposal, not an implementation feature |
| MSC4269 | ⬛ ● | open | Unambiguous mentions in body | client-side message body composition |
| MSC4268 | ⬛ ● | merged | Sharing room keys for past messages | client-only E2EE key sharing; server only relays to-device and stores media |
| MSC4261 | ⬛ ● | open | “Do not encrypt for device” flag | do_not_encrypt is a client-only device key flag |
| MSC4253 | ⬛ ● | open | Modifying or rejecting accepted MSCs | Spec process MSC; no implementable behavior |
| MSC4252 | ⬛ ● | open | Extensible Events modification: State event handling | Client-side guidance for extensible state events |
| MSC4238 | ⬛ ● | open | Pinned events read marker | Client-set m.read.pinned_events account data only |
| MSC4231 | ⬛ ● | open | Backwards compatibility for media captions | Client-side caption fallback rendering; no server work |
| MSC4229 | ⬛ ● | open | Pass through unsigned data from /keys/upload to /keys-query | template/example proposal; no real change |
| MSC4209 | ⬛ ● | open | Updating endpoints in-place | deprecation policy clarification; no code |
| MSC4192 | ⬛ ● | open | Comparison of proposals for ignoring invites | comparison/research document, not a feature |
| MSC4183 | ⬛ ● | merged | Additional Error Codes for submitToken endpoints | identity service API; Tuwunel is not an IS |
| MSC4179 | ⬛ ● | open | Moderation event hiding | client-side rendering hint |
| MSC4178 | ⬛ ● | merged | Error codes for requestToken | new 3PID requestToken error codes; 3PID stack is out of scope for Tuwunel per… |
| MSC4161 | ⬛ ● | open | Crypto terminology for non-technical users | crypto terminology guidance for clients |
| MSC4159 | ⬛ ● | merged | Remove the deprecated name attribute on HTML anchor elements | client-side HTML rendering recommendation |
| MSC4157 | ⬛ ● | open | Delayed Events (widget-api) | widget-api only; not a homeserver concern |
| MSC4153 | ⬛ ● | merged | Exclude non-cross-signed devices | client-side cross-signing enforcement and to-device filtering |
| MSC4150 | ⬛ ● | open | m.allow recommendation for moderation policy lists | m.allow recommendation for policy lists is client-side |
| MSC4147 | ⬛ ● | merged | Including device keys with Olm-encrypted to-device messages | sender_device_keys in Olm plaintext is client-side |
| MSC4146 | ⬛ ● | open | Shared Message Drafts | shared message drafts via m.drafts rooms is client-side |
| MSC4144 | ⬛ ● | open | Per-message profiles | m.per_message_profile is client-only event content |
| MSC4142 | ⬛ ● | merged | Remove unintentional intentional mentions in replies | client-side guidance for m.mentions in replies |
| MSC4139 | ⬛ ● | open | Bot buttons & conversations | m.prompts mixin is client-only event content |
| MSC4132 | ⬛ ● | merged | Deprecate Linking to an Event Against a Room Alias. | deprecation of event-on-room-alias URIs is client-only |
| MSC4131 | ⬛ ● | open | Handling m.room.encryption events | client-side guidance on handling m.room.encryption events |
| MSC4119 | ⬛ ● | open | Voluntary content flagging | client-only m.room.context flagging mixin; server is content-agnostic |
| MSC4114 | ⬛ ● | open | Matrix as a password manager | client-only password manager via rooms; no server-side requirements |
| MSC4092 | ⬛ ● | open | Enforce tests around sensitive parts of the specification | process MSC about test enforcement; no protocol changes |
| MSC4077 | ⬛ ● | merged | Improved process for handling deprecated HTML features | process MSC for HTML feature deprecation; no server work |
| MSC4073 | ⬛ ● | open | Shepherd teams | process MSC about SCT shepherd teams; not protocol |
| MSC4062 | ⬛ ◐ | open | Add a push rule tweak to disable email notification | Tuwunel has no email pusher; tweak only affects email pushers. |
| MSC4052 | ⬛ ● | closed | Hiding read receipts UI in certain rooms | Pure client-side hint via m.hide_ui state event. |
| MSC4050 | ⬛ ● | open | MXID verification | Pure client/third-party signaling via custom event types. |
| MSC4039 | ⬛ ● | open | Access the Content repository with the Widget API | Widget API extension; entirely client-to-widget scope. |
| MSC4036 | ⬛ ● | open | Room organization by promoting threads | Pure client UI behavior toggled by m.promote_threads state event. |
| MSC4032 | ⬛ ● | open | Asset Collections | Asset Collections defines client-side data structures for 3D worlds; no serve… |
| MSC4027 | ⬛ ● | open | Custom Images in Reactions | custom image reactions, m.annotation key semantics |
| MSC4016 | ⬛ ● | open | Streaming and resumable E2EE file transfer with random access | streaming E2EE file transfer needs new media transport |
| MSC4015 | ⬛ ● | closed | Voluntary Bot indicators | voluntary bot flag, profile and member event content |
| MSC4013 | ⬛ ● | open | Poll history cache | client convention using existing relations API |
| MSC4006 | ⬛ ● | open | Answered Elsewhere for VoIP | VoIP m.call.hangup reason value, client concern |
| MSC4004 | ⬛ ● | open | unified view of identity service | identity service API, not homeserver |
| MSC4003 | ⬛ ● | open | Semantic table attributes | HTML table sanitization is client concern |
| MSC4002 | ⬛ ● | open | Walkie talkie | Walkie-talkie real-time voice, vague client-driven proposal |
| MSC3979 | ⬛ ● | open | Revised feature profiles | client feature profiles, not a homeserver concern |
| MSC3977 | ⬛ ● | open | Introduction | IETF MIMI framework draft, not a Matrix MSC |
| MSC3973 | ⬛ ● | open | Search users in the user directory with the Widget API | widget API extension; client/embedder feature |
| MSC3972 | ⬛ ● | closed | Lexicographical strings as an ordering mechanism | client-side ordering algorithm |
| MSC3956 | ⬛ ● | open | Extensible Events - Encrypted Events | client-side extensible encrypted event format |
| MSC3949 | ⬛ ● | open | Power Level Tags | Power-level tag state event is client UX; no server enforcement. |
| MSC3948 | ⬛ ● | open | Repository room for Thirdroom | ThirdRoom 3D-asset repository room type; no homeserver semantics. |
| MSC3935 | ⬛ ● | open | Cute Events against social distancing | Client-side cute event msgtype; no server behavior. |
| MSC3923 | ⬛ ● | merged | Bringing Matrix into the IETF process | Spec-process MSC about IETF coordination; no homeserver code. |
| MSC3919 | ⬛ ● | open | Matrix Message Format (IETF/MIMI) | IETF informational draft on Matrix message format; not a server feature. |
| MSC3918 | ⬛ ● | open | Matrix Message Transport (IETF/MIMI) | IETF informational draft about Matrix as MIMI transport; not a server feature. |
| MSC3910 | ⬛ ● | open | Content tokens for media | [→ MSC3916] |
| MSC3908 | ⬛ ● | open | Expiring Policy List Recommendations | expiring policy field interpreted by clients/bots |
| MSC3907 | ⬛ ● | open | Mute Policy Recommendation | mute policy recommendation enforced by clients/bots |
| MSC3906 | ⬛ ● | open | Protocol to use an existing Matrix client session to complete login and setup… | [→ MSC4108] |
| MSC3903 | ⬛ ● | open | X25519 Elliptic-curve Diffie-Hellman ephemeral for establishing secure channe… | [→ MSC4108] client-to-client X25519 ECDH; no server role |
| MSC3898 | ⬛ ● | open | Native Matrix VoIP signalling for cascaded SFUs | VoIP SFU signalling is opaque events between clients |
| MSC3892 | ⬛ ● | open | Custom Emotes with Encryption | custom emotes are pure client/state-event feature |
| MSC3888 | ⬛ ◐ | open | Voice Broadcast | voice broadcast is opaque events, no server change required |
| MSC3886 | ⬛ ● | open | Simple client rendezvous capability | [→ MSC4108] |
| MSC3880 | ⬛ ● | open | dummy replies for Olm | client-side Olm dummy event behavior |
| MSC3879 | ⬛ ● | open | Trusted key forwards | E2EE key forwarding flag is client-side |
| MSC3869 | ⬛ ● | open | Read event relations with the Widget API | Widget API extension; homeservers do not implement widget API |
| MSC3868 | ⬛ ◐ | open | Room Contribution | custom state event for room contribution, no server requirements |
| MSC3846 | ⬛ ● | open | Allowing widgets to access TURN servers | widget TURN access; client-widget API only |
| MSC3842 | ⬛ ● | open | Power levels on message (extensible) events | proposal body is TBD; nothing to implement |
| MSC3839 | ⬛ ● | open | primary-identity-as-key | speculative login system replacement; not actionable as a proposal |
| MSC3819 | ⬛ ● | open | Allowing widgets to send/receive to-device messages | widget to-device is client-widget API only |
| MSC3817 | ⬛ ● | open | Allow widgets to create rooms | widget API only, no server-side surface |
| MSC3815 | ⬛ ● | open | 3D Worlds | 3D worlds is client-side room type and state events; no server behavior |
| MSC3813 | ⬛ ● | open | Obfuscated events | obfuscated events; client-side dummy traffic |
| MSC3812 | ⬛ ● | open | Hint buttons in messages | hint buttons in messages; client UI |
| MSC3803 | ⬛ ● | open | Matrix Widget API v2 | Widget API v2 issue placeholder |
| MSC3796 | ⬛ ◐ | open | Auth/linking for content repo (and enforcing GDPR erasure) | [→ MSC3916] |
| MSC3790 | ⬛ ● | closed | Register Clients | client launcher registry; client-only |
| MSC3784 | ⬛ ● | open | Using room type of m.policy for policy rooms | m.policy room-type identifier; informational only |
| MSC3783 | ⬛ ● | merged | Fixed base64 for SAS verification | SAS MAC scheme is client-to-client crypto |
| MSC3780 | ⬛ ● | open | Knocking on action=join | matrix-uri client UX fallback for knock |
| MSC3775 | ⬛ ● | open | Markup Locations for Audiovisual Media | event content schema for media markup |
| MSC3768 | ⬛ ● | open | Push rule action for in-app notifications | [→ MSC2625] |
| MSC3755 | ⬛ ● | open | Member pronouns | pronouns are client member-content fields |
| MSC3752 | ⬛ ● | open | Markup locations for text | event content schema for text markup locations |
| MSC3751 | ⬛ ● | open | Allowing widgets to read account data | Widget API permission, not a homeserver concern |
| MSC3746 | ⬛ ● | closed | Render image data in reactions | [→ MSC4027] image reactions are client-only event content |
| MSC3735 | ⬛ ◐ | open | Add device information to m.room_key.withheld message | client-side to-device field; server relays unchanged |
| MSC3725 | ⬛ ● | open | Content warnings | client-side content warning event content; no server changes |
| MSC3700 | ⬛ ◐ | merged | Deprecate plaintext sender_key | client-side ignoring of sender_key/device_id; server is transparent |
| MSC3676 | ⬛ ◐ | merged | Transitioning away from reply fallbacks. | client-side reply-fallback transition rules; no server gate |
| MSC3662 | ⬛ ● | open | Allow Widgets to share user MxIds to the client | widget-to-client API; no server involvement |
| MSC3644 | ⬛ ● | open | Extensible Events: Edits and replies | client-side extensible event format; no server-side dispatch |
| MSC3639 | ⬛ ● | open | Matrix for the social media use case | client-side social media room/event conventions; no server changes |
| MSC3635 | ⬛ ● | open | Early Media for VoIP | client-side VoIP signalling; no server changes required |
| MSC3592 | ⬛ ● | open | Markup locations for PDF documents | client-side PDF markup event types; no server implementation required |
| MSC3588 | ⬛ ● | closed | WIP: MSC3588: Encrypted Stories As Rooms | client-only feature; explicitly says no server changes required |
| MSC3531 | ⬛ ● | open | Letting moderators hide messages pending moderation | client-only m.visibility event; server explicitly unchanged |
| MSC3517 | ⬛ ● | closed | “Mention” Pushrule | [→ MSC3952] |
| MSC3510 | ⬛ ● | open | Let users with the same power level kick/ban/demote each other. | [→ MSC3915] |
| MSC3382 | ⬛ ● | open | Inline message Attachments | PR-style amendment to MSC2881, not a standalone proposal |
| MSC3302 | ⬛ ● | closed | Stories via To-Device-Messaging | client uses generic to-device which is supported |
| MSC3291 | ⬛ ● | merged | Muting in VoIP calls | server passes call events opaquely; ruma has the type |
| MSC3288 | ⬛ ● | merged | Add room type to /_matrix/identity/v2/store-invite API | room type passed to /_matrix/identity/v2/store-invite; identity-server endpoi… |
| MSC3282 | ⬛ ● | closed | Expose enable_set_displayname in capabilities response | [→ MSC3283] |
| MSC3279 | ⬛ ● | closed | Expose enable_set_displayname in capabilities response | [→ MSC3283] |
| MSC3270 | ⬛ ● | closed | Symmetric megolm backup | server stores backup auth_data/session_data opaquely |
| MSC3265 | ⬛ ● | closed | Login and SSSS with a Single Password | client-only construction; explicitly no server-side changes |
| MSC3255 | ⬛ ● | closed | Use SRV record for homeservers discovery by clients | client-side discovery via SRV; closed proposal |
| MSC3246 | ⬛ ● | open | Audio waveforms (extensible events) | client message-content field; no server role |
| MSC3245 | ⬛ ● | open | Voice messages (using extensible events) | client message type; ruma feature enabled but server has no role |
| MSC3230 | ⬛ ● | open | Spaces top level order | m.space_order is account_data; uses generic API |
| MSC3226 | ⬛ ● | merged | Per-room spell check | per-room spellcheck language is account_data; no server logic |
| MSC3184 | ⬛ ● | open | Challenges Messages | client-only challenge message types |
| MSC3160 | ⬛ ● | open | Attach timezone metadata to time information in messages | client-only HTML <time> markup in messages |
| MSC3131 | ⬛ ● | open | Verifying with QR codes v2 | client-only QR verification v2 method names |
| MSC3124 | ⬛ ● | closed | Handling spoilers in plain-text message fallback | client-only spoiler fallback handling |
| MSC3122 | ⬛ ● | merged | Deprecate starting key verifications without requesting first | client-only deprecation of to-device verification start |
| MSC3086 | ⬛ ● | open | Asserted Identity for VoIP Calls | client VoIP event content; server transparent |
| MSC3077 | ⬛ ● | merged | Support for multi-stream VoIP | merged; sdp_stream_metadata is event content |
| MSC3074 | ⬛ ● | closed | Proposal for URIs conforming to RFC 3986 syntax. | client URI scheme; not a server feature |
| MSC3068 | ⬛ ● | closed | Compliance tiers | informational compliance terminology only |
| MSC3067 | ⬛ ● | closed | Prevent/remove legacy groups from being in the spec | meta MSC; spec-process decision to drop legacy groups |
| MSC3062 | ⬛ ● | open | Bot verification | client-only verification method |
| MSC3061 | ⬛ ● | open | Sharing room keys for past messages | client-only; sender-flagged room key property |
| MSC3015 | ⬛ ● | open | Room state personal overrides | client-only; account data convention |
| MSC3009 | ⬛ ● | open | Websocket transport for client <–> widget communications | client to widget transport; not server-side |
| MSC3008 | ⬛ ● | open | Scoped access for widgets | widget client/UA concern; obsoleted by OIDC scopes |
| MSC2997 | ⬛ ● | open | Add t-shirt | joke proposal; t-shirt design |
| MSC2974 | ⬛ ● | open | Widgets: Re-exchange capabilities | widget-side request_capabilities; client-only |
| MSC2949 | ⬛ ● | open | Proposal to clarify “Requires auth” and “Rate-limited” in the spec | spec-text clarification; no homeserver behavior |
| MSC2931 | ⬛ ● | open | Widget navigate permission | widget navigate capability; client-only |
| MSC2881 | ⬛ ● | open | Message Attachments | new event content schema (m.attachment relation); generic event passthrough |
| MSC2876 | ⬛ ● | closed | Allowing widgets to read events in a room | widget read_events action; client-only |
| MSC2874 | ⬛ ● | merged | Single SSSS | client interpretation of SSSS default key; account data passthrough |
| MSC2873 | ⬛ ● | open | Identifying clients and user settings in widgets | widget URL template variables and theme_change; client-only |
| MSC2872 | ⬛ ● | open | Move the widget title to the root | widget definition field reorder; client-only |
| MSC2871 | ⬛ ● | open | Sending approved capabilities back to the widget | widget-only feature; homeserver not involved |
| MSC2813 | ⬛ ● | open | Handling invalid Widget API requests | client/widget error handling rules |
| MSC2810 | ⬛ ◐ | closed | Consistent globs specification | closed glob spec doc; ACLs/push rules already use existing globs |
| MSC2801 | ⬛ ● | merged | Make it explicit that event bodies are untrusted data | spec note: clients should treat events as untrusted |
| MSC2790 | ⬛ ● | open | Widgets - Prompting for user input within the client | client-side widget modal API |
| MSC2781 | ⬛ ● | merged | Remove reply fallbacks from the specification | removes client-side reply fallback; client behavior change |
| MSC2779 | ⬛ ● | closed | Clarify that event IDs are globally unique | spec clarification issue; closed; no server behavior change |
| MSC2775 | ⬛ ◐ | open | Lazy loading room membership over federation | [→ MSC3706/MSC3902] |
| MSC2774 | ⬛ ● | merged | Giving widgets their ID so they can communicate | client widget URL template variable |
| MSC2771 | ⬛ ● | closed | Bookmarks | client-side bookmarks via account_data; closed |
| MSC2765 | ⬛ ● | merged | Widget avatars | client-side widget definition field |
| MSC2762 | ⬛ ● | open | Allowing widgets to send/receive events | client-side widget API; homeserver not involved |
| MSC2758 | ⬛ ● | merged | Common grammar for textual identifiers | meta grammar guideline for future identifiers; not directly implementable |
| MSC2747 | ⬛ ● | open | Transferring VoIP Calls | Client-only m.call.replaces event semantics |
| MSC2723 | ⬛ ● | open | Forwarded message metadata | Client-side m.forwarded content field only |
| MSC2713 | ⬛ ● | merged | Remove deprecated Identity Service endpoints | Identity Service endpoints; not a homeserver feature |
| MSC2697 | ⬛ ◐ | closed | Device dehydration | [→ MSC3814] Superseded by MSC3814 dehydration v2; closed |
| MSC2644 | ⬛ ● | open | matrix.to URI syntax v2 | matrix.to URI syntax; client-only |
| MSC2630 | ⬛ ◐ | merged | Checking public keys in SAS verification | Client SAS verification crypto; server transports key.verification events |
| MSC2618 | ⬛ ● | open | Helping others with mandatory implementation guides | Spec process MSC; no homeserver behavior |
| MSC2604 | ⬛ ◐ | merged | Parameters for Login Fallback | Client login fallback HTML page; Tuwunel does not serve /login/fallback |
| MSC2589 | ⬛ ◐ | closed | Improve replies | Client reply rendering; closed MSC; server ignores reply_body fields |
| MSC2582 | ⬛ ◐ | merged | Remove mimetype from EncryptedFile object | Removes mimetype example from spec; pure spec/client cleanup |
| MSC2579 | ⬛ ○ | closed | Improved tagging support | Client tag-ordering account_data; server stores opaquely |
| MSC2557 | ⬛ ◐ | merged | Clarifications on spoilers | Client-only spoiler rendering clarification |
| MSC2545 | ⬛ ◐ | open | Image Packs (Emoticons & Stickers) | Client emote/sticker pack rendering; server stores account_data and state events |
| MSC2530 | ⬛ ◐ | merged | Body field as media caption | Client rendering of body+filename for media msgtypes |
| MSC2529 | ⬛ ◐ | open | Use existing m.room.message/m.text events as captions for images | [→ MSC2530] Client-only relation/caption rendering; superseded by MSC2530 |
| MSC2516 | ⬛ ◐ | closed | Add a new message type for voice messages | Client-only msgtype; server does no msgtype-specific handling |
| MSC2475 | ⬛ ○ | closed | API versioning | Spec process meta-MSC about API version naming; closed |
| MSC2474 | ⬛ ◐ | open | Add key backup version to SSSS account data | Client-side SSSS field; server stores account_data opaquely |
| MSC2472 | ⬛ ◐ | merged | Symmetric SSSS | Client-side SSSS crypto; server only stores account_data |
| MSC2461 | ⬛ ◐ | closed | Proposal for Authenticated Content Repository API | [→ MSC3916] |
| MSC2427 | ⬛ ◐ | open | Proposal for JSON-based message formatting | Client-only message formatting alternative to HTML |
| MSC2425 | ⬛ ● | open | Remove Authentication on /submitToken Identity Service API | Identity Server endpoint; not a homeserver concern |
| MSC2422 | ⬛ ◐ | merged | Allow color as attribute for <font> in messages | Client HTML sanitizer change for <font color> |
| MSC2413 | ⬛ ● | open | Remove client_secret | 3PID-only proposal; Tuwunel does not support 3PID |
| MSC2399 | ⬛ ◐ | merged | Reporting that decryption keys are withheld | Client-only m.room_key.withheld to-device event |
| MSC2398 | ⬛ ◐ | open | proposal to allow mxc:// in the “a” tag within messages | Client HTML rendering policy for <a href=mxc:> |
| MSC2390 | ⬛ ◐ | closed | On the EDU-to-PDU transition. | Process MSC; closed; recommends no further EDU use |
| MSC2389 | ⬛ ◐ | closed | Toward the EDU-to-PDU transition: Typing. | Typing as PDU; closed proposal, Tuwunel uses EDU |
| MSC2388 | ⬛ ◐ | open | Toward the EDU-to-PDU transition: Read Receipts. | Receipts as PDU; superseded direction, Tuwunel uses EDU |
| MSC2385 | ⬛ ◐ | open | Disable URL Previews, alternative method | Client-only url_previews array on m.room.message |
| MSC2376 | ⬛ ◐ | closed | Disable URL Previews | Client-only HTML attribute hint; server has no role |
| MSC2366 | ⬛ ◐ | merged | Key verification flow additions: m.key.verification.ready and `m.key.verifi… | Client-side verification flow over to-device; server transports |
| MSC2359 | ⬛ ◐ | open | E2E Encrypted SFU VoIP conferencing via Matrix | [→ MSC3401] Architectural sketch for client+SFU; no homeserver requirements |
| MSC2354 | ⬛ ◐ | open | Device to device streaming file transfers | Client-only WebRTC signaling over event types; server transports opaquely |
| MSC2346 | ⬛ ● | open | MSC 2346: Bridge information state event | m.bridge state event; bridge/client concern |
| MSC2324 | ⬛ ● | merged | Facilitating early releases of software dependent on spec | Process change about FCP and stable prefixes |
| MSC2320 | ⬛ ● | merged | Versions information for identity servers | Identity server endpoint, not homeserver |
| MSC2315 | ⬛ ● | open | Allow users to select “none” as an integration manager | Client account_data m.integrations toggle |
| MSC2313 | ⬛ ● | merged | Moderation policies as rooms (ban lists) | State events m.policy.rule.*; no homeserver enforcement |
| MSC2312 | ⬛ ● | merged | URI scheme for Matrix | Client-side URI scheme; no homeserver endpoint required |
| MSC2299 | ⬛ ● | open | Proposal to add m.textfile msgtype | Client-only msgtype m.textfile |
| MSC2291 | ⬛ ● | open | Configuration to Control Crawling | Bot-only advisory state event; no homeserver behavior |
| MSC2290 | ⬛ ● | merged | Separate Endpoints for Binding Threepids | separate 3PID bind endpoints; 3PID stack is out of scope for Tuwunel per meth… |
| MSC2284 | ⬛ ● | merged | Making the identity server optional during discovery | Client-side .well-known FAIL_PROMPT behavior |
| MSC2270 | ⬛ ◐ | open | Proposal for ignoring invites | Client account_data scheme; server stores account data transparently |
| MSC2265 | ⬛ ◐ | merged | Proposal for mandating case folding when processing e-mail addresses | Email casefold only relevant inside 3PID code path; 3PID not impl |
| MSC2264 | ⬛ ● | merged | Add an unstable feature flag to MSC2140 for clients to detect support | Process amendment to MSC2140 only |
| MSC2263 | ⬛ ◐ | merged | Give homeservers the ability to handle their own 3PID registrations/password … | 3PID flow not implemented; threepid endpoints return ThreepidDenied |
| MSC2241 | ⬛ ◐ | merged | Key verification in DMs | Client-side verification flow over m.room.message; server passes events trans… |
| MSC2232 | ⬛ ● | open | Expose Homeserver Email Configuration in Registration Parameters | proposal text is the empty MSC template |
| MSC2230 | ⬛ ◐ | merged | Store Identity Server in Account Data | client behavior over generic account data; HS already supports account data |
| MSC2229 | ⬛ ● | merged | Allowing 3PID Owners to Rebind | [→ MSC2290] obsoleted by MSC2290; tuwunel disables 3PID |
| MSC2211 | ⬛ ● | open | Identity Servers Storing Threepid Hashes at Rest | identity server storage details; not HS |
| MSC2192 | ⬛ ● | open | Inline widgets | client extensible event m.embed; no server logic |
| MSC2191 | ⬛ ● | merged | Markup for mathematical messages | client formatted_body rendering only |
| MSC2184 | ⬛ ● | merged | Allow the HTML <details> tag in messages | client HTML rendering; no server impact |
| MSC2162 | ⬛ ◐ | open | Signaling Errors at Bridges | client/bridge event types; no homeserver enforcement |
| MSC2140 | ⬛ ● | merged | Terms of Service API for Identity Servers and Integration Managers | IS+IM ToS API; HS-side 3pid/unbind+delete absent but 3PID disabled |
| MSC2134 | ⬛ ● | merged | Identity Hash Lookups | identity-server only; tuwunel is HS |
| MSC2078 | ⬛ ● | merged | Sending Third-Party Request Tokens via the Homeserver | 3PID requestToken via homeserver; 3PID stack is out of scope for Tuwunel per … |
| MSC2063 | ⬛ ◐ | closed | Add “server information” public API proposal | closed; no real proposal text (template file only) |
| MSC2010 | ⬛ ● | merged | MSC 2010: Proposal to add client-side spoilers | client-side rendering of data-mx-spoiler in formatted_body |
| MSC1961 | ⬛ ● | merged | Integration manager authentication | merged; integration-manager auth API is on the manager, not homeserver |
| MSC1960 | ⬛ ● | merged | OpenID Connect information exchange for widgets | OpenID Connect exchange for widgets; the new flow is widget-to-client, server… |
| MSC1959 | ⬛ ● | open | Sticker picker API | branch; sticker picker API on integration manager, not homeserver |
| MSC1958 | ⬛ ● | closed | Widget architecture changes | client widget account_data shape; servers do not interpret widget content |
| MSC1957 | ⬛ ● | merged | Integration manager discovery | integration-manager discovery; integration managers are out of scope for Tuwu… |
| MSC1956 | ⬛ ● | open | Integrations API | branch; integrations API is integration-manager scope, not homeserver |
| MSC1951 | ⬛ ◐ | open | Custom emoji and sticker packs in Matrix | branch; client/integration manager concept; uses generic rooms |
| MSC1935 | ⬛ ◐ | closed | Key validity enforcement | [→ MSC2076] closed; superseded by MSC2076 |
| MSC1920 | ⬛ ◐ | open | Alternative texts for stickers | branch; client-side rendering field on m.sticker; no server logic |
| MSC1915 | ⬛ ● | merged | MSC 1915 - Add unbind 3PID APIs | 3PID unbind APIs; 3PID stack is out of scope for Tuwunel per methodology |
| MSC1902 | ⬛ ● | open | Splitting the media repo into a client-side and server-side component | [→ MSC3916] |
| MSC1849 | ⬛ ◐ | open | Proposal for aggregations via relations | [→ MSC2674/MSC2675/MSC2676] |
| MSC1840 | ⬛ ● | closed | Typed rooms | closed; superseded by m.room.create type field used by MSC1772 |
| MSC1781 | ⬛ ● | open | Proposal for associations for DIDs and DID names | identity-server endpoints for DID validation; not a homeserver concern |
| MSC1779 | ⬛ ● | merged | Proposal for Open Governance of Matrix.org | governance/foundation document; not a homeserver feature |
| MSC1762 | ⬛ ● | open | Support user-owned identifiers as new 3PID type | identity-server feature (m.did 3PID type); not a homeserver concern |
| MSC1722 | ⬛ ● | closed | Support for displaying math(s) in messages | client-side rendering of MathML in formatted_body; servers do not interpret |
| MSC1719 | ⬛ ● | merged | Olm unwedging | client-only behavior (m.dummy, session re-creation rate-limit) |
| MSC1703 | ⬛ ● | closed | encrypting recovery keys for online megolm backups | amendment PR to MSC1687; closed without merge |
| MSC1680 | ⬛ ● | closed | cross-signing of devices to simplify key verification | empty Google-doc stub; cross-signing specified in MSC1756 |
| MSC1544 | ⬛ ● | merged | Key verification using QR codes | amendment PR to MSC1543; no separate proposal text |
| MSC1543 | ⬛ ● | merged | Bi-directional Key verification using QR codes | client-only QR verification over send-to-device; server is opaque |
| MSC1318 | ⬛ ● | closed | Proposal for Open Governance of Matrix.org | [→ MSC1779] governance proposal; not a homeserver feature |
| MSC1310 | ⬛ ● | closed | Proposal for a media information API | empty Google-doc stub; media info API never specified |
| MSC1286 | ⬛ ● | open | Formally spec an API for interacting with integration managers | legacy 2018 issue tracked via cross-repo redirect; integration manager API is… |
| MSC1267 | ⬛ ● | closed | Interactive key verification using short authentication strings | stub Google doc; SAS verification specified later (MSC2241+); client-only fea… |
| MSC1236 | ⬛ ● | open | Matrix Widget API v2 | legacy 2018 issue tracked via redirect; widget API v2 is a client-side concern |
| MSC1225 | ⬛ ● | closed | Extensible event types & fallback in Matrix | empty Google-doc stub; extensible events specified later in MSC1767 |
| MSC1215 | ⬛ ● | closed | Groups as Rooms | [→ MSC1772] empty Google-doc stub; groups feature dropped in favor of Spaces |
| MSC1194 | ⬛ ● | closed | A way for HSes to remove bindings from ISes (aka unbind) | identity-server unbind feature; one-line proposal, abandoned |
| MSC971 | ⬛ ● | closed | Add groups stuff to spec | [→ MSC1772] groups stuff superseded by Spaces (MSC1772); proposal is doc link… |
| MSC701 | ⬛ ◐ | open | Auth/linking for content repo (and enforcing GDPR erasure) | legacy 2016 issue tracked via redirect; auth/linking for content repo address… |
| MSC688 | ⬛ ● | closed | Room Summaries (was: Calculate room names server-side) | stub Google doc; room summary work moved to heroes/MSC688 in spec |
| MSC455 | ⬛ ● | closed | Do we want to specify a matrix:// URI scheme for rooms? (SPEC-5) | [→ MSC2312] stub Google doc; matrix:// URI scheme superseded by matrix: URI (… |
| MSC441 | ⬛ ● | closed | Support for Reactions / Aggregations | [→ MSC2675/MSC2676] stub-only Google doc; superseded by MSC2675/MSC2676 react… |
Tuwunel Complement Test Results
Tuwunel runs the Complement Matrix homeserver acceptance suite. Raw results are committed to tests/complement/results.jsonl; full server and test logs to tests/complement/logs.jsonl.
Counts
-
Test groups: 201. Passing: 69.2%
- ✅
pass: 139 - 🟨
some: 27 - ❌
fail: 33 - ⬛
skip: 2
- ✅
-
Subtests: 591. Passing: 68.2%
- ✅
pass: 403 - ❌
fail: 175 - ⬛
skip: 13
- ✅
All Top-Level Tests
| Test | Status | Subtests |
|---|---|---|
TestACLs | ✅ | – |
TestAddAccountData | ✅ | 2/0/0 |
TestArchivedRoomsHistory | 🟨 | 3/2/1 |
TestAsyncUpload | ✅ | 6/0/0 |
TestAvatarUrlUpdate | ✅ | – |
TestBannedUserCannotSendJoin | ✅ | – |
TestCanRegisterAdmin | ⬛ | – |
TestCannotKickLeftUser | ✅ | – |
TestCannotKickNonPresentUser | ❌ | – |
TestCannotSendKnockViaSendKnockInMSC3787Room | ✅ | 6/0/0 |
TestCannotSendNonJoinViaSendJoinV2 | ✅ | 6/0/0 |
TestCannotSendNonKnockViaSendKnock | ✅ | 6/0/0 |
TestCannotSendNonLeaveViaSendLeaveV2 | ✅ | 6/0/0 |
TestChangePassword | ✅ | 5/0/0 |
TestChangePasswordPushers | ✅ | 2/0/0 |
TestClientSpacesSummary | 🟨 | 1/4/0 |
TestClientSpacesSummaryJoinRules | ✅ | – |
TestComplementCanCreateValidV12Rooms | ✅ | – |
TestContent | ✅ | – |
TestContentCSAPIMediaV1 | ✅ | – |
TestContentMediaV1 | ✅ | – |
TestCorruptedAuthChain | ❌ | – |
TestCumulativeJoinLeaveJoinSync | ✅ | – |
TestDeactivateAccount | ✅ | 4/0/0 |
TestDelayedEvents | 🟨 | 6/7/1 |
TestDeletingDeviceRemovesDeviceLocalNotificationSettings | ❌ | 0/1/0 |
TestDemotingUsersViaUsersDefault | ✅ | – |
TestDeviceListUpdates | 🟨 | 4/6/0 |
TestDeviceListsUpdateOverFederation | ❌ | 0/3/0 |
TestDeviceListsUpdateOverFederationOnRoomJoin | ❌ | – |
TestDeviceManagement | 🟨 | 6/1/0 |
TestDisplayNameUpdate | ✅ | – |
TestE2EKeyBackupReplaceRoomKeyRules | ✅ | 3/0/0 |
TestEvent | ✅ | 3/0/0 |
TestEventAuth | ✅ | 2/0/0 |
TestEventRelationships | ❌ | – |
TestFederatedClientSpaces | ✅ | – |
TestFederatedEventRelationships | ❌ | – |
TestFederationKeyUploadQuery | ❌ | 0/2/0 |
TestFederationRedactSendsWithoutEvent | ✅ | – |
TestFederationRejectInvite | ✅ | – |
TestFederationRoomsInvite | 🟨 | 8/2/0 |
TestFederationThumbnail | ✅ | – |
TestFetchEvent | ✅ | – |
TestFetchEventNonWorldReadable | ❌ | – |
TestFetchEventWorldReadable | ✅ | – |
TestFetchHistoricalInvitedEventFromBeforeInvite | ❌ | – |
TestFetchHistoricalInvitedEventFromBetweenInvite | ✅ | – |
TestFetchHistoricalJoinedEventDenied | ❌ | – |
TestFetchHistoricalSharedEvent | ✅ | – |
TestFetchMessagesFromNonExistentRoom | ✅ | – |
TestFilter | ✅ | – |
TestFilterMessagesByRelType | ❌ | – |
TestGappedSyncLeaveSection | ✅ | – |
TestGetFilteredRoomMembers | ✅ | 3/0/0 |
TestGetMissingEventsGapFilling | ❌ | – |
TestGetRoomMembers | ✅ | – |
TestGetRoomMembersAtPoint | ❌ | – |
TestInboundCanReturnMissingEvents | ❌ | 0/4/0 |
TestInboundFederationKeys | ✅ | – |
TestInboundFederationProfile | ✅ | 2/0/0 |
TestInboundFederationRejectsEventsWithRejectedAuthEvents | ❌ | – |
TestInviteFiltering | 🟨 | 3/8/0 |
TestInviteFromIgnoredUsersDoesNotAppearInSync | ✅ | – |
TestIsDirectFlagFederation | ✅ | – |
TestIsDirectFlagLocal | ✅ | – |
TestJoinFederatedRoomFailOver | ✅ | – |
TestJoinFederatedRoomFromApplicationServiceBridgeUser | ❌ | 0/1/0 |
TestJoinFederatedRoomWithUnverifiableEvents | ✅ | 4/0/0 |
TestJoinViaRoomIDAndServerName | ✅ | – |
TestJson | ✅ | 3/0/0 |
TestJumpToDateEndpoint | 🟨 | 6/8/0 |
TestKeyChangesLocal | ✅ | 1/0/0 |
TestKeyClaimOrdering | ✅ | – |
TestKeysQueryWithDeviceIDAsObjectFails | ✅ | – |
TestKnockRestrictedRoomsLocalJoinNoCreatorsUsesPowerLevelsV11 | ✅ | – |
TestKnockRestrictedRoomsLocalJoinNoCreatorsUsesPowerLevelsV12 | ✅ | – |
TestKnockRoomsInPublicRoomsDirectory | ✅ | – |
TestKnockRoomsInPublicRoomsDirectoryInMSC3787Room | ✅ | – |
TestKnocking | 🟨 | 16/9/0 |
TestKnockingInMSC3787Room | 🟨 | 16/9/0 |
TestLeakyTyping | ✅ | – |
TestLeaveEventInviteRejection | ✅ | – |
TestLeaveEventVisibility | ❌ | – |
TestLeftRoomFixture | 🟨 | 2/3/0 |
TestLocalPngThumbnail | ✅ | 2/0/0 |
TestLogin | ✅ | 8/0/0 |
TestLogout | ✅ | 4/0/0 |
TestMSC3757OwnedState | ❌ | – |
TestMSC3967 | ✅ | – |
TestMSC4289PrivilegedRoomCreators | ✅ | 11/0/0 |
TestMSC4289PrivilegedRoomCreators_Additional | ✅ | – |
TestMSC4289PrivilegedRoomCreators_AdditionalCreatorsAndInvited | ✅ | – |
TestMSC4289PrivilegedRoomCreators_AdditionalValidation | ✅ | 5/0/0 |
TestMSC4289PrivilegedRoomCreators_InvitedAreCreators | ✅ | – |
TestMSC4289PrivilegedRoomCreators_Upgrades | ✅ | – |
TestMSC4291RoomIDAsHashOfCreateEvent | ✅ | – |
TestMSC4291RoomIDAsHashOfCreateEvent_AuthEventsOmitsCreateEvent | ✅ | – |
TestMSC4291RoomIDAsHashOfCreateEvent_CannotSendCreateEvent | ✅ | – |
TestMSC4291RoomIDAsHashOfCreateEvent_RoomIDIsOnCreateEvent | ✅ | – |
TestMSC4291RoomIDAsHashOfCreateEvent_UpgradedRooms | ❌ | – |
TestMSC4297StateResolutionV2_1_includes_conflicted_subgraph | ✅ | – |
TestMSC4297StateResolutionV2_1_starts_from_empty_set | ✅ | – |
TestMSC4308ThreadSubscriptionsSlidingSync | ❌ | 0/2/0 |
TestMSC4311FullCreateEventOnStrippedState | ❌ | – |
TestMediaConfig | ✅ | – |
TestMediaFilenames | ✅ | 25/0/0 |
TestMediaWithoutFileName | ✅ | 4/0/0 |
TestMediaWithoutFileNameCSMediaV1 | ✅ | 4/0/0 |
TestMembersLocal | 🟨 | 3/2/0 |
TestMembershipOnEvents | ❌ | – |
TestMessagesOverFederation | 🟨 | 3/2/0 |
TestNetworkPartitionOrdering | ✅ | – |
TestNotPresentUserCannotBanOthers | ✅ | – |
TestOlderLeftRoomsNotInLeaveSection | ✅ | – |
TestOutboundFederationEventSizeGetMissingEvents | ❌ | – |
TestOutboundFederationIgnoresMissingEventWithBadJSONForRoomVersion6 | ❌ | – |
TestOutboundFederationProfile | ✅ | 1/0/0 |
TestOutboundFederationSend | ✅ | – |
TestPartialStateJoin | ❌ | 0/58/7 |
TestPollsLocalPushRules | ❌ | 0/1/0 |
TestPowerLevels | ✅ | 3/0/0 |
TestPresence | 🟨 | 4/1/0 |
TestPresenceSyncDifferentRooms | ✅ | – |
TestProfileAvatarURL | ✅ | 2/0/0 |
TestProfileDisplayName | ✅ | 2/0/0 |
TestPublicRooms | ✅ | 9/0/0 |
TestPushRuleCacheHealth | ✅ | – |
TestPushRuleRoomUpgrade | ❌ | 0/5/0 |
TestPushSync | ✅ | 5/0/0 |
TestRedact | ✅ | 1/0/0 |
TestRegistration | ✅ | 19/0/4 |
TestRelations | ✅ | – |
TestRelationsPagination | ✅ | – |
TestRelationsPaginationSync | ✅ | – |
TestRemoteAliasRequestsUnderstandUnicode | ✅ | – |
TestRemotePngThumbnail | ✅ | 2/0/0 |
TestRemotePresence | ❌ | 0/2/0 |
TestRemoteTyping | ✅ | – |
TestRemovingAccountData | ✅ | 4/0/0 |
TestRequestEncodingFails | ❌ | 0/1/0 |
TestRestrictedRoomsLocalJoin | 🟨 | 4/1/0 |
TestRestrictedRoomsLocalJoinInMSC3787Room | 🟨 | 4/1/0 |
TestRestrictedRoomsLocalJoinNoCreatorsUsesPowerLevelsV11 | ✅ | – |
TestRestrictedRoomsLocalJoinNoCreatorsUsesPowerLevelsV12 | ✅ | – |
TestRestrictedRoomsRemoteJoin | 🟨 | 4/1/0 |
TestRestrictedRoomsRemoteJoinInMSC3787Room | 🟨 | 4/1/0 |
TestRestrictedRoomsRemoteJoinLocalUser | ✅ | – |
TestRestrictedRoomsRemoteJoinLocalUserInMSC3787Room | ✅ | – |
TestRestrictedRoomsSpacesSummaryFederation | ✅ | – |
TestRestrictedRoomsSpacesSummaryLocal | ✅ | – |
TestRoomAlias | ✅ | 5/0/0 |
TestRoomCanonicalAlias | ✅ | 10/0/0 |
TestRoomCreate | ✅ | 15/0/0 |
TestRoomCreationReportsEventsToMyself | 🟨 | 3/3/0 |
TestRoomDeleteAlias | 🟨 | 7/2/0 |
TestRoomForget | 🟨 | 5/3/0 |
TestRoomImageRoundtrip | ✅ | – |
TestRoomMembers | 🟨 | 7/3/0 |
TestRoomMessagesLazyLoading | ✅ | – |
TestRoomMessagesLazyLoadingLocalUser | ✅ | – |
TestRoomReadMarkers | ✅ | – |
TestRoomReceipts | ✅ | 1/0/0 |
TestRoomSpecificUsernameAtJoin | ✅ | 5/0/0 |
TestRoomSpecificUsernameChange | ✅ | 5/0/0 |
TestRoomState | ✅ | 15/0/0 |
TestRoomSummary | ✅ | – |
TestRoomsInvite | ✅ | 9/0/0 |
TestSearch | 🟨 | 4/3/0 |
TestSendAndFetchMessage | ✅ | – |
TestSendJoinPartialStateResponse | ✅ | – |
TestSendMessageWithTxn | ✅ | – |
TestServerCapabilities | ✅ | – |
TestServerNotices | ⬛ | – |
TestSync | 🟨 | 10/3/0 |
TestSyncFilter | ✅ | 2/0/0 |
TestSyncLeaveSection | ✅ | 3/0/0 |
TestSyncOmitsStateChangeOnFilteredEvents | ✅ | – |
TestSyncTimelineGap | ✅ | 2/0/0 |
TestTentativeEventualJoiningAfterRejecting | ✅ | – |
TestThreadSubscriptions | 🟨 | 1/7/0 |
TestThreadedReceipts | ❌ | – |
TestThreadsEndpoint | ❌ | – |
TestToDeviceMessages | ✅ | – |
TestToDeviceMessagesOverFederation | ✅ | 1/0/0 |
TestTxnIdWithRefreshToken | ✅ | – |
TestTxnIdempotency | ❌ | – |
TestTxnIdempotencyScopedToDevice | ✅ | – |
TestTxnInEvent | ✅ | – |
TestTxnScopeOnLocalEcho | ✅ | – |
TestTyping | ✅ | 3/0/0 |
TestUnknownEndpoints | 🟨 | 4/1/0 |
TestUnrejectRejectedEvents | ✅ | – |
TestUploadKey | 🟨 | 6/2/0 |
TestUploadKeyIdempotency | ✅ | – |
TestUploadKeyIdempotencyOverlap | ✅ | – |
TestUrlPreview | ✅ | – |
TestUserAppearsInChangedDeviceListOnJoinOverFederation | ✅ | – |
TestVersionStructure | ✅ | 1/0/0 |
TestWithoutOwnedState | ✅ | 7/0/0 |
TestWriteMDirectAccountData | ✅ | – |
Testing and Delivery
Tuwunel’s CI is built on Docker Buildx Bake, a recipe tree for multi-stage builds that produces cache-friendly multi-layer images. Each layer accomplishes a specific task and its outputs are automatically reused by any subsequent layer that depends on it. The result is a large combinatorial matrix of build configurations where only the final layer — the one that actually differs — must be rebuilt.
The .github/workflows/ directory contains the pipeline description actually
used for CI. It is a thin client: switches and patch panels that dispatch to the
docker builder, not the mainframe itself. Every job is a path to an invocation of
docker buildx bake. The same builder, with the same targets, is available to
any developer locally — no service lockin required.
All scripts, Dockerfiles, and the bake configuration live in docker/ at the
project root.
Delivery Pipeline
The pipeline is organized into four sequential phases that gate on each other. A failure in any phase blocks all later ones, preventing partially-built deliverables from reaching users.
| Phase | Access | Description |
|---|---|---|
| Lint | everything (unless masked) | format, spelling, security audit, dead links, clippy |
| Test | everything (unless masked) | unit, integration, smoke, Complement, Matrix SDK |
| Package | main, test, releases, PRs (limited) | binaries, containers, distro packages, docs |
| Publish | main and tagged releases only | container registries, GitHub Pages |
Chapters
- Docker Builder —
bake.hcl, target hierarchy, layer caching,bake.sh - Matrix Selectors — cargo profiles, feature sets, toolchains, CPU targets
- Pipeline Phases — stages of the pipeline required for delivery
- Complement Testing — protocol compliance testing, local usage
Docker Bake System
The builder is implemented using Docker Buildx Bake,
a declarative build system for multi-stage Docker builds. All configuration lives
in docker/bake.hcl. The shell script docker/bake.sh is the user-facing entry
point for invoking it.
A conventional CI system runs sequential jobs that each build everything they
need from scratch, or caches at coarse granularity (the whole Cargo registry,
the whole target/ directory). Bake instead models the build as a directed
acyclic graph of image layers. Intermediate layers are shared across every
target that needs them: if deps-build already exists in the cache, every
target that depends on it — unit, clippy, install, deb — skips
rebuilding it and jumps straight to their unique work.
This is especially valuable for the build matrix: compiling dependencies once per (toolchain, feature set) pair and reusing that layer for all tests and build targets on top of it cuts total CI time dramatically.
Directory layout
| File | Role |
|---|---|
docker/bake.hcl | Declarative target graph; all variables, groups, and targets |
docker/bake.sh | Shell wrapper; sets defaults, invokes docker buildx bake |
docker/complement.sh | Complement test orchestrator; runs after bake builds the images |
docker/Dockerfile.* | “Library functions” — generic, variable-driven build stages |
The Dockerfile.* files are intentionally generic: they accept ARG variables
and are reused across many targets via Bake’s variable substitution, rather than
having one Dockerfile per use case.
Builder setup
Bake requires a named BuildKit builder. Locally, the builder name defaults to
owo. In CI it is the GitHub actor name ($GITHUB_ACTOR). Create one with:
cat <<EOF > buildkitd.toml
[system]
platformsCacheMaxAge = "504h"
[worker.oci]
enabled = true
gc = true
reservedSpace = "64GB"
maxUsedSpace = "128GB"
[[worker.oci.gcpolicy]]
reservedSpace = "64GB"
maxUsedSpace = "128GB"
all = true
EOF
docker buildx create \
--name owo \
--bootstrap \
--buildkitd-config ./buildkitd.toml \
--driver docker-container \
--buildkitd-flags "--allow-insecure-entitlement network.host"
The --allow-insecure-entitlement network.host flag is required for
Complement (which needs host networking during testing). It can be omitted if
you only need to run other targets.
bake.sh usage
docker/bake.sh is the standard entry point. It accepts target names as
positional arguments and reads matrix dimensions from environment variables.
# Basic usage
docker/bake.sh <target> [<target>...]
# With environment overrides (singular form for convenience)
cargo_profile="release" rust_toolchain="stable" docker/bake.sh install
# Multiple targets
docker/bake.sh fmt clippy
Targets
Bake groups collect multiple targets under one name for convenience:
| Group | Members |
|---|---|
lints | audit, check, clippy, fmt, lychee, typos |
tests | All unit, integ, doc, bench targets |
smoke | smoke-version, smoke-startup, smoke-perf, smoke-valgrind, smoke-nix |
integration | rust-sdk-integ, rust-sdk-valgrind |
complement | All complement tester/testee targets |
installs | install, static, docker, oci |
pkg | book, docs, deb, deb-install, rpm, rpm-install, nix |
publish | ghcr_io, docker_io |
default | A representative single-vector build |
Variables
Variables at the top of bake.hcl control every aspect of the build. Most have
sensible defaults for local use and are overridden by bake.sh or the CI
workflows for production runs.
Single-value selectors
| Environment | Default | Description |
|---|---|---|
cargo_profile | test | Single profile (wrapped into JSON array) |
feat_set | all | Single feature set |
rust_toolchain | nightly | Single toolchain |
rust_target | x86_64-unknown-linux-gnu | Single Rust target triple |
sys_name | debian | Base OS name |
sys_version | testing-slim | Base OS version |
sys_target | x86_64-v1-linux-gnu | CPU optimization level |
For multi-value matrix runs (as used in CI), set the cargo_profiles,
feat_sets, etc. variables directly to JSON arrays.
Multi-value defaults
variable "cargo_profiles" { default = "[\"test\", \"release\"]" }
variable "feat_sets" { default = "[\"none\", \"default\", \"all\"]" }
variable "rust_toolchains" { default = "[\"nightly\", \"stable\"]" }
variable "rust_targets" { default = "[\"x86_64-unknown-linux-gnu\"]" }
variable "sys_names" { default = "[\"debian\"]" }
variable "sys_versions" { default = "[\"testing-slim\"]" }
variable "sys_targets" { default = "[\"x86_64-v1-linux-gnu\"]" }
These are escaped JSON arrays passed as strings.
Target Hierarchy
Every target is a leaf or branch in a single dependency tree. Building a leaf automatically triggers all its transitive dependencies. The tree (bottom to top):
system ← Debian base image + runtime packages
├── rust ← rustup + toolchain installation
│ └── rustup
├── rocksdb-fetch ← RocksDB source checkout
│ └── rocksdb-build ← compiled librocksdb
│ └── rocksdb
├── valgrind ← Valgrind installation
└── perf ← Linux perf tools
kitchen ← build environment (inherits system + rust + rocksdb)
└── builder ← adds nproc/env setup
source ← project source code (via git checkout)
└── preparing ← sets up cargo workspace for chef
└── ingredients ← cargo-chef recipe
└── recipe ← pre-built dependency layer (cargo-chef cook)
deps-base ← base dependency compilation
deps-build ← full dep build (links recipe + kitchen)
deps-build-tests ← test deps
deps-build-bins ← binary deps
deps-clippy ← clippy-specific deps
deps-check ← check-specific deps
build ← compiles the main binary
build-tests ← compiles test binary
build-bins ← compiles all binaries
build-deb ← deb packaging build
build-rpm ← rpm packaging build
# Lint targets (leaves):
fmt typos audit lychee check clippy
# Test targets (leaves):
doc unit unit-valgrind integ integ-valgrind
smoke-version smoke-startup smoke-perf smoke-valgrind smoke-nix
rust-sdk-integ rust-sdk-valgrind
# Complement targets (leaves):
complement-base complement-config
complement-tester complement-tester-valgrind
complement-testee complement-testee-valgrind
# Install/package targets (leaves):
install install-valgrind install-perf static
docker oci
deb deb-install rpm rpm-install nix build-nix
book docs
# Publish targets (leaves):
ghcr_io docker_io
Build Matrix
Tuwunel’s CI tests a large combinatorial space of build configurations. The matrix has six independent dimensions. Not all combinations are valid or useful, so the bake configuration and workflow files apply exclusions to keep the total job count tractable.
Cargo Profiles
| Profile | Use | Notes |
|---|---|---|
test | Most tests and linting | Debug-like; assertions enabled; fastest to compile |
release | Production builds and smoke tests | Thin LTO; the profile shipped to users |
bench | Benchmarks and Valgrind | Optimized with debug symbols for profiling |
release-debuginfo | Release + full debug info | For crash analysis without sacrificing optimization |
release-native | Performance benchmarking on CI hardware | target-cpu=native; non-portable; never shipped |
Cargo Feature Sets
The feature sets are named groups of Cargo features, defined in docker/bake.hcl
as the cargo_feat_sets map.
none
No optional features. Produces the smallest, most minimal binary. Used to verify that nothing in the default code path accidentally depends on an optional feature.
default
The feature set shipped to users in standard packages:
brotli_compression, element_hacks, gzip_compression, io_uring,
jemalloc, jemalloc_conf, media_thumbnail, release_max_log_level,
systemd, url_preview, zstd_compression
logging
All of default (without release_max_log_level) plus features useful for
development and diagnostics:
blurhashing, bzip2_compression, console, direct_tls, jemalloc_prof,
jemalloc_stats, ldap, lz4_compression, perf_measurements,
sentry_telemetry, tokio_console, tuwunel_mods
all
Everything, including release_max_log_level. The most exhaustive compilation
target; used for clippy, doc tests, and integration tests.
Note
The feature
direct_tlsis always added to every build regardless of the selected feature set (cargo_features_alwaysinbake.hcl).
Rust Toolchains
| Toolchain key | Resolved to | Used for |
|---|---|---|
nightly | The current nightly (or a specific nightly in CI) | Default for all builds; required for some flags and rustfmt options |
stable | The MSRV from rust-toolchain.toml | Release packages, Nix smoke test |
The bake.sh script reads rust-toolchain.toml to resolve stable to the
project’s minimum supported Rust version, ensuring released binaries are always
built against a pinned toolchain rather than whatever stable happens to be
current. Nix additionally verifies this with a SHA256 check in flake.nix.
Rust Targets (Cross-compilation)
These are the --target values passed to Cargo:
| Target | Architecture |
|---|---|
x86_64-unknown-linux-gnu | x86_64 Linux (primary) |
aarch64-unknown-linux-gnu | ARM64 Linux |
Cross-compilation to aarch64 runs on ARM64 GitHub Actions runners and
produces binaries for Raspberry Pi 4+, AWS Graviton, and Apple Silicon (via
Rosetta or native under Linux).
CPU Optimization Levels (System Targets)
The sys_target dimension controls which x86_64 microarchitecture level the
binary is compiled for. This affects both Rust compiler flags and, for targets
that include RocksDB, the native library build.
| sys_target | x86_64 level | Key instruction sets | Recommended for |
|---|---|---|---|
x86_64-v1-linux-gnu | v1 baseline | SSE2 | Any x86_64 CPU; used for compatibility testing |
x86_64-v2-linux-gnu | v2 | + POPCNT, SSE3, SSE4.1/4.2, SSSE3 | CPUs from ~2009+; minimum for good RocksDB CRC32 performance |
x86_64-v3-linux-gnu | v3 | + AVX, AVX2, BMI1/2, F16C, FMA, MOVBE | Haswell (2013) and newer; the recommended shipping target |
x86_64-v4-linux-gnu | v4 | + AVX-512F/BW/CD/DQ/VL | Skylake-X/Ice Lake server; highest throughput |
aarch64-v8-linux-gnu | ARMv8 | NEON, AES, SHA | All 64-bit ARM |
Warning
Running a binary compiled for a higher level than the host CPU supports causes an
Illegal Instruction(SIGILL) crash immediately on startup. The generic deployment guide includes a command to determine which level your CPU supports.
RocksDB benefits significantly from hardware CRC32 (available from v2 onward)
and from SIMD compression routines (improved further at v3). For production
deployments, -v3- binaries are the recommended default.
System Images
The base OS image for build and runtime containers. Currently only one system is tested:
| sys_name | sys_version | Base image |
|---|---|---|
debian | testing-slim | debian:testing-slim |
Linking Mode
Static versus dynamic linking is selected automatically based on the profile and toolchain:
| Condition | Linking |
|---|---|
release or bench profile with stable toolchain | Static (-C relocation-model=static, +crt-static) |
| All other combinations | Dynamic (PIC, shared libc) |
Static linking produces fully portable binaries that run on any Linux system without matching library versions. Dynamic linking is used during development and testing because it produces faster incremental builds.
Matrix Combinations in Practice
The CI does not test every possible combination — the cross-product of all dimensions would be in the thousands. Instead, each workflow job selects a slice of the matrix appropriate to its task:
| Job | Profiles | Feature sets | Toolchains | CPU targets |
|---|---|---|---|---|
clippy | test, release, bench | none, default, all | nightly, stable | v1 |
unit | test | all | nightly | v1 |
bench | bench | all | nightly | v3 |
memcheck | bench | all | nightly | v3 |
smoke | test, release | default, all | nightly | v1, v3 |
rust-sdk-integ | test, release | all | nightly | v1 |
complement | test, release | all | nightly | v1 |
binary (package) | release | default | stable | v1, v2, v3, v4, aarch64-v8 |
container (package) | release | default | stable | v3 |
distro (package) | release | default | stable | v1 |
The test.yml workflow embeds about 51 explicit exclusions to remove
combinations that are redundant, impossible, or not worth the resource cost.
Pipeline Phases
The CI pipeline runs in four sequential phases. Each phase must pass in its entirety before the next begins. This structure is intentional: linting fails fast before expensive test jobs start; tests must pass before build artifacts are created; packages are only published when everything before them succeeded.
Tip
Commit messages can suppress individual phases to avoid waiting for unrelated results when only interested in specific jobs.
Flag in commit message Effect [ci no lint]Skip the linting phase [ci no test]Skip the testing phase [ci no build]Skip build targets within tests/packages [ci no package]Skip the package phase [ci no publish]Skip the publish phase [ci only it]Run only integration tests
1. Linting Phase
Linting runs first and fails fast. None of the heavier test or build jobs start
until every lint check passes. All lint jobs are in
docker/ targets invoked through .github/workflows/lint.yml.
Format (fmt)
All Rust source must be formatted with nightly rustfmt. The check runs
cargo fmt --check and produces a diff showing every formatting violation if
it fails.
- Dockerfile:
docker/Dockerfile.cargo.fmt - Bake target:
fmt - Toolchain: nightly (nightly rustfmt has formatting options stable does not)
Typos (typos)
All text in the repository — source code, comments, and Markdown documentation — is checked for spelling errors using the typos tool.
- Dockerfile:
docker/Dockerfile.cargo.typos - Bake target:
typos
Security Audit (audit)
cargo audit checks every dependency in Cargo.lock against the
RustSec Advisory Database. Any unignored advisory causes
a failure. This job runs on branch pushes and all “fat” refs (main, test,
release tags); it is skipped on pull requests to avoid blocking contributors
on advisories they did not introduce.
- Bake target:
audit - Workflow condition:
is_branch || is_fat
Dead Link Check (lychee)
lychee scans all Markdown files for broken hyperlinks. Internal links (relative paths) and external URLs are both checked.
- Dockerfile:
docker/Dockerfile.cargo.lychee - Bake target:
lychee
Cargo Check (check)
cargo check is used as a fail-faster form of clippy, although the latter is
not gated on the former, the intent is to cancel all other tasks before they
inevitably fail on the same error. Only one instance of this is usually run.
- Bake target:
check
Clippy (clippy)
cargo clippy runs with --deny warnings — any lint warning is a build
failure. Crucially, clippy runs for every combination of the build matrix:
all cargo profiles, all feature sets, stable and nightly toolchains. This
catches warnings that only manifest under specific feature combinations or with
a specific compiler, which individual developers rarely exercise locally.
- Bake target:
clippy - Policy: zero warnings permitted across all matrix dimensions
2. Testing Phase
The testing phase covers correctness at every level, from individual functions to full Matrix protocol compliance. The most complex workflow. Receives matrix selectors and per-test enable flags. Jobs are roughly ordered by cost: cheaper jobs run first; expensive jobs (Nix, Complement) run last or are gated on cheaper jobs passing.
Cargo Doc Tests (doc)
cargo test --doc runs all code examples embedded in rustdoc comments. Runs on
release profile with all features and nightly toolchain on x86_64-v1.
- Bake target:
doc
Cargo Unit and Integration Tests (unit)
cargo test runs the module-level unit tests and any crate-associated or
binary-associated integration. Runs on test profile with all features
and nightly toolchain.
- Bake target:
unit - Valgrind variant:
unit-valgrind
Cargo Benchmark Tests (bench)
Benchmarks are compiled (but not executed at full duration) to verify they build
without error. Runs on bench profile with all features and nightly.
- Bake target:
doc(bench profile)
Valgrind Memory Checking (memcheck)
The integration test binary runs under Valgrind to detect memory errors at runtime. Configured with:
--error-exitcode=1 --exit-on-first-error=yes --undef-value-errors=no --leak-check=no
Runs on bench profile with all features and nightly on x86_64-v3.
- Bake target:
integ-valgrind
Smoke Tests (smoke)
Smoke tests exercise a running Tuwunel binary without a full client. These tests run on main and test branches; some are skipped on pull requests to reduce costs of scaling public contribution.
- Bake group:
smoke - Dockerfile:
docker/Dockerfile.smoketest
Nix Smoke Test (nix)
Verifies that nix build still produces a working binary. Beyond the binary,
the Nix build also runs its own set of tests:
- Cargo unit and integration tests inside the Nix sandbox
- Requires all dependency Git commits to be reachable from a branch
- Validates that the SHA256 hash in
flake.nixmatches the currentrust-toolchain.tomlversion — this catches MSRV bumps where the flake was not updated
Only runs on main and test branches with the stable toolchain. Pull
requests skip this test intentionally — it is expensive and rarely fails for
routine code changes.
- Bake target:
smoke-nix - Dockerfile:
docker/Dockerfile.nix
Matrix Rust SDK Integration Tests (rust-sdk-integ)
Runs the matrix-rust-sdk
client-server integration test suite against a live Tuwunel process. A Tuwunel
binary from a prior build layer is started in the background while cargo test
runs the SDK’s integration test crate right in the docker builder.
- Debug mode (
testprofile): Exercises code paths with assertions enabled and catches logic errors that only appear with unoptimized code. - Release mode (
releaseprofile): Ensures tests pass without concurrency hazards or other issues that optimized builds can expose.
For the above two matrix variations rust-sdk-integ is run for both and
rust-sdk-valgrind is run for release profile only.
- Bake targets:
rust-sdk-integ,rust-sdk-valgrind - Dockerfile:
docker/Dockerfile.matrix-rust-sdk
Matrix Compliance (complement-tester/complement-testee)
See Complement Testing for full details. Briefly: the Complement suite runs its Go tests against containerized Tuwunel instances via the Docker daemon, verifying conformance to the Matrix client-server and server-server specifications.
The complement job builds two images (tester and testee) via bake.yml.
The compliance job then runs docker/complement.sh, extracts result files
from the tester container, and runs git diff against the stored baseline.
The diff is uploaded as an artifact regardless of pass/fail, so reviewers can
see exactly what changed in compliance.
A file named tests/complement/tuwunel.log contains the server logs from the
last run extracted from the testee container and is also uploaded as an
artifact.
3. Package Phase
The package phase produces all distributable artifacts. It runs after tests pass. The set of artifacts produced varies by branch:
| Branch / ref | Artifacts produced |
|---|---|
| Pull requests | Minimal: binary for the pushed architecture only |
| Regular branches | Binaries + containers for x86_64 |
main / test | Full set including distro packages and all CPU variants |
Release tags (v*) | Full set, identical to main |
test branch only | Post-package install checks (deb and rpm) |
rustdoc (docs)
Builds the Rust API documentation with cargo doc. Runs on release profile,
all features, nightly, x86_64-v1.
- Bake target:
docs - Output:
/usr/src/tuwunel/target/<triple>/doc/
mdBook (book)
Builds this documentation site using mdBook.
Runs on release profile, stable, x86_64-v1.
- Bake target:
book - Output:
/book/
Static Binaries (binary)
Compiles statically linked binaries for all supported targets. Packaged as release assets on GitHub.
- Bake targets:
install(dynamic),static(static),oci,docker - CPU variants:
x86_64-v1,x86_64-v2,x86_64-v3,x86_64-v4,aarch64-v8(full set on main/test/release; reduced set on other branches)
Container Images (container)
Builds OCI and Docker images for docker and oci container formats.
- Bake targets:
docker,oci - Output: images pushed in the publish phase to GHCR and Docker Hub
Distro Packages (distro)
Builds .deb, .rpm, and Nix packages. Only on main, test, and release
tags.
- Dockerfiles:
docker/Dockerfile.cargo.deb,docker/Dockerfile.cargo.rpm
Post-package Checks (checks)
Installs the built .deb and .rpm into a clean environment and verifies the
package installs and the binary runs. Only on the test branch, where the most
thorough validation is desired.
- Bake targets:
deb-install,rpm-install
4. Publish Phase
The publish phase runs only for main and release tags. All publication happens
here — nothing is pushed to external services during earlier phases. This keeps
publication atomic: if anything fails before this phase, no partially-complete
releases reach users.
GitHub Pages (documents)
Uploads the built mdBook site and rustdoc to GitHub Pages. Skipped for draft releases.
- Workflow job:
documents
Container Registries (containers)
Pushes container images to two registries simultaneously:
- GitHub Container Registry (
ghcr.io/matrix-construct/tuwunel) - Docker Hub (
docker.io)
Images are compressed with zstd at level 11. Each image is tagged with the Git SHA and branch/tag name.
- Workflow job:
containers
Manifest Bundles (bundles / delivery)
After all per-image pushes complete, manifest lists are assembled that combine
multi-architecture images under a single tag. Manifests are pushed to both
registries in the delivery job, which depends on bundles and documents
both completing first.
| Manifest tag | Applied to |
|---|---|
main | main branch pushes |
preview | Release candidates (-rc, pre-release tags) |
latest | Full release tags |
Matrix Protocol Compliance Testing
Complement is the Matrix protocol compliance test suite. It verifies that a homeserver correctly implements the Matrix client-server and federation specifications by running Go-based tests against live server instances. We maintain a fork at github.com/matrix-construct/complement with fixes for tests that had issues upstream.
Complement works differently from ordinary test runners: it uses the Docker daemon API to create isolated networks and start fresh homeserver instances for each test (or small group of tests). This requires the test runner itself to have access to the Docker socket — which creates a docker-in-docker situation when running inside a container.
Tuwunel’s CI handles this by splitting the work into two images and a shell script:
complement-tester ← contains the Complement binary (the Go test runner)
complement-testee ← contains the Tuwunel binary (the system under test)
docker/complement.sh runs complement-tester as a container with:
- The host Docker socket mounted (
-v /var/run/docker.sock:/var/run/docker.sock) - Host networking (
--network=host) for test containers to communicate
The tester container then orchestrates the test run by telling the host Docker
daemon to start complement-testee instances, connect them, and run the tests.
The Go code inside the tester runs against these instances, while all the
container management happens on the host’s daemon — not truly docker-in-docker.
Running locally
Prerequisites:
- Docker with BuildKit and a configured builder.
network.hostentitlement enabled on the builder
Basic run (debug build, all tests)
docker/bake.sh complement-tester complement-testee && docker/complement.sh
Release build
cargo_profile="release" docker/bake.sh complement-tester complement-testee && \
cargo_profile="release" docker/complement.sh
Release build with stable Rust toolchain
export cargo_profile="release"
export rust_toolchain="stable"
docker/bake.sh complement-tester complement-testee && docker/complement.sh
Run a single test by name
docker/bake.sh complement-tester complement-testee && \
docker/complement.sh TestAvatarUrlUpdate
Run multiple tests by pattern
docker/bake.sh complement-tester complement-testee && \
docker/complement.sh "TestAvatarUrlUpdate|TestEvent"
The argument to complement.sh becomes the complement_run regex, passed to
Go’s test runner via -run.
View logs from the last run
cat tests/complement/logs.jsonl | jq .
Results and baseline comparison
After each run, complement.sh extracts two files from the tester container:
| File | Contents |
|---|---|
tests/complement/results.jsonl | Pass/fail result for every test case |
tests/complement/logs.jsonl | Full verbose output from the test run |
The results file is version-controlled. complement.sh runs
git diff --exit-code on it: if results match the stored baseline exactly,
the script exits 0 (pass). Any change — new failures or new passes — produces
a non-zero exit and the diff is printed. In CI, the diff and logs are uploaded
as artifacts for review. It tracks changes in compliance. A test that was
previously failing and starts passing is caught just as clearly as a regression.
The baseline must be deliberately updated when the compliance profile
intentionally changes.
Image naming
Images are tagged with the full matrix vector so they can be unambiguously matched:
complement-tester--<sys_name>--<sys_version>--<sys_target>
complement-testee--<cargo_profile>--<rust_toolchain>--<rust_target>--<feat_set>--<sys_name>--<sys_version>--<sys_target>
For example, a debug run produces:
complement-tester--debian--testing-slim--x86_64-v1-linux-gnu
complement-testee--test--nightly--x86_64-unknown-linux-gnu--all--debian--testing-slim--x86_64-v1-linux-gnu
Nix-based Complement (unmaintained)
Warning
The workflow described below is not currently maintained and is no longer recommended. It is preserved here for any contributor who wants to reconstitute it.
Tuwunel’s flake.nix provides a complement package that builds a Complement
OCI image using Nix. With Nix and direnv installed
(run direnv allow after setup):
./bin/complement "$COMPLEMENT_SRC"— build, run, and output logs to the specified paths; also outputs the OCI image toresultnix build .#complement— build just the OCI image (a.tar.gzatresult)nix build .#linux-complement— for macOS hosts needing a Linux image
Pre-built images from CI artifacts can be placed at
complement_oci_image.tar.gz in the project root and used without Nix.
Hot Reloading (“Live” Development)
Note that hot reloading has not been refactored in quite a while and is not guaranteed to work at this time.
Summary
When developing in debug-builds with the nightly toolchain, Tuwunel is modular
using dynamic libraries and various parts of the application are hot-reloadable
while the server is running: http api handlers, admin commands, services,
database, etc. These are all split up into individual workspace crates as seen
in the src/ directory. Changes to sourcecode in a crate rebuild that crate and
subsequent crates depending on it. Reloading then occurs for the changed crates.
Release builds still produce static binaries which are unaffected. Rust’s soundness guarantees are in full force. Thus you cannot hot-reload release binaries.
Requirements
Currently, this development setup only works on x86_64 and aarch64 Linux glibc.
musl explicitly does not support hot reloadable libraries, and does not
implement dlclose. macOS does not fully support our usage of RTLD_GLOBAL
possibly due to some thread-local issues. This Rust issue may be of
relevance, specifically this comment. It may be possible to get it working
on only very modern macOS versions such as at least Sonoma, as currently loading
dylibs is supported, but not unloading them in our setup, and the cited comment
mentions an Apple WWDC confirming there have been TLS changes to somewhat make
this possible.
As mentioned above this requires the nightly toolchain. This is due to reliance
on various Cargo.toml features that are only available on nightly, most
specifically RUSTFLAGS in Cargo.toml. Some of the implementation could also be
simpler based on other various nightly features. We hope lots of nightly
features start making it out of nightly sooner as there have been dozens of very
helpful features that have been stuck in nightly (“unstable”) for at least 5+
years that would make this simpler. We encourage greater community consensus to
move these features into stability.
This currently only works on x86_64/aarch64 Linux with a glibc C library. musl C
library, macOS, and likely other host architectures are not supported (if other
architectures work, feel free to let us know and/or make a PR updating this).
This should work on GNU ld and lld (rust-lld) and gcc/clang, however if you
happen to have linker issues it’s recommended to try using mold or gold
linkers, and please let us know in the Tuwunel Matrix room the linker
error and what linker solved this issue so we can figure out a solution. Ideally
there should be minimal friction to using this, and in the future a build script
(build.rs) may be suitable to making this easier to use if the capabilities
allow us.
Usage
As of 19 May 2024, the instructions for using this are:
-
Have patience. Don’t hesitate to join the Tuwunel Matrix room to receive help using this. As indicated by the various rustflags used and some of the interesting issues linked at the bottom, this is definitely not something the Rust ecosystem or toolchain is used to doing.
-
Install the nightly toolchain using rustup. You may need to use
rustup override set nightlyin your local Tuwunel directory, or usecargo +nightlyfor all actions. -
Uncomment
cargo-featuresat the top level / root Cargo.toml -
Scroll down to the
# Developer profilesection and uncomment ALL the rustflags for each dev profile and their respective packages. -
In each workspace crate’s Cargo.toml (everything under
src/*ANDdeps/rust-rocksdb/Cargo.toml), uncomment thedylibcrate type under[lib]. -
Due to this rpath issue, you must export the
LD_LIBRARY_PATHenvironment variable to your nightly Rust toolchain library directory. If using rustup (hopefully), use this:export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$HOME/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/ -
Start the server. You can use
cargo +nightly runfor this along with the standard. -
Make some changes where you need to.
-
In a separate terminal window in the same directory (or using a terminal multiplexer like tmux), run the build Cargo command
cargo +nightly build. Cargo should only rebuild what was changed / what’s necessary, so it should not be rebuilding all the crates. -
In your Tuwunel server terminal, hit/send
CTRL+Csignal. This will tell Tuwunel to find which libraries need to be reloaded, and reloads them as necessary. -
If there were no errors, it will tell you it successfully reloaded
#modules, and your changes should now be visible. Repeat 7 - 9 as needed.
To shutdown Tuwunel in this setup, hit/send CTRL+\. Normal builds still
shutdown with CTRL+C as usual.
Steps 1 - 5 are the initial first-time steps for using this. To remove the hot reload setup, revert/comment all the Cargo.toml changes.
As mentioned in the requirements section, if you happen to have some linker
issues, try using the -fuse-ld= rustflag and specify mold or gold in all the
rustflags definitions in the top level Cargo.toml, and please let us know in
the Tuwunel Matrix room the problem. mold can be installed typically
through your distro, and gold is provided by the binutils package.
It’s possible a helper script can be made to do all of this, or most preferably
a specially made build script (build.rs). cargo watch support will be
implemented soon which will eliminate the need to manually run cargo build all
together.
Addendum
Conduit was inherited as a single crate without modularity or reloading in its design. Reasonable partitioning and abstraction allowed a split into several crates, though many circular dependencies had to be corrected. The resulting crates now form a directed graph as depicted in figures below. The interfacing between these crates is still extremely broad which is not mitigable.
Initially hot_lib_reload was investigated but found appropriate for a
project designed with modularity through limited interfaces, not a large and
complex existing codebase. Instead a bespoke solution built directly on
libloading satisfied our constraints. This required relatively minimal
modifications and zero maintenance burden compared to what would be required
otherwise. The technical difference lies with relocation processing: we leverage
global bindings (RTLD_GLOBAL) in a very intentional way. Most libraries and
off-the-shelf module systems (such as hot_lib_reload) restrict themselves
to local bindings (RTLD_LOCAL). This allows them to release software to
multiple platforms with much greater consistency, but at the cost of burdening
applications to explicitly manage these bindings. In our case with an optional
feature for developers, we shrug any such requirement to enjoy the cost/benefit
on platforms where global relocations are properly cooperative.
To make use of RTLD_GLOBAL the application has to be oriented as a directed
acyclic graph. The primary rule is simple and illustrated in the figure below:
no crate is allowed to call a function or use a variable from a crate below
it.

When a symbol is referenced between crates they become bound: crates cannot be
unloaded until their calling crates are first unloaded. Thus we start the
reloading process from the crate which has no callers. There is a small problem
though: the first crate is called by the base executable itself! This is solved
by using an RTLD_LOCAL binding for just one link between the main executable
and the first crate, freeing the executable from all modules as no global
binding ever occurs between them.

Proper resource management is essential for reliable reloading to occur. This is a very basic ask in RAII-idiomatic Rust and the exposure to reloading hazards is remarkably low, generally stemming from poor patterns and practices. Unfortunately static analysis doesn’t enforce reload-safety programmatically (though it could one day), for now hazards can be avoided by knowing a few basic do’s and dont’s:
-
Understand that code is memory. Just like one is forbidden from referencing free’d memory, one must not transfer control to free’d code. Exposure to this is primarily from two things:
- Callbacks, which this project makes very little use of.
- Async tasks, which are addressed below.
-
Tie all resources to a scope or object lifetime with greatest possible symmetry (locality). For our purposes this applies to code resources, which means async blocks and tokio tasks.
- Never spawn a task without receiving and storing its JoinHandle.
- Always wait on join handles before leaving a scope or in another cleanup function called by an owning scope.
-
Know any minor specific quirks documented in code or here:
- Don’t use
tokio::spawn, instead use ourHandleincore/server.rs, which is reachable in most of the codebase viaservices()or other state. This is due to some bugs or assumptions made in tokio, as it happens inunsafe {}blocks, which are mitigated by circumventing some thread-local variables. Using runtime handles is good practice in any case.
- Don’t use
The initial implementation PR is available here.
Interesting related issues/bugs
- DT_RUNPATH produced in binary with rpath = true is wrong (cargo)
- Disabling MIR Optimization in Rust Compilation (cargo)
- Workspace-level metadata (cargo-deb)
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people.
- Being respectful of differing opinions, viewpoints, and experiences.
- Giving and gracefully accepting constructive feedback.
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience.
- Focusing on what is best not just for us as individuals, but for the overall community.
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind.
- Trolling, insulting or derogatory comments, and personal or political attacks.
- Public or private harassment.
- Publishing others’ private information, such as a physical or email address, without their explicit permission.
- Other conduct which could reasonably be considered inappropriate in a professional setting.
Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement over encrypted DM to <@jason:tuwunel.chat>, <@june:vern.cc>, <@dasha_uwu:linuxping.win> or over email to jasonzemos@gmail.com.
All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident.
Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by Mozilla’s code of conduct enforcement ladder.
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.