Add whoami-based sync worker routing for user-level sticky sessions

This adds a new routing mechanism for sync workers that resolves access tokens
to usernames via Synapse's whoami endpoint, enabling true user-level sticky
routing regardless of which device or token is used.

Previously, sticky routing relied on parsing the username from native Synapse
tokens (`syt_<base64 username>_...`), which only works with native Synapse auth
and provides device-level stickiness at best. This new approach works with any
auth system (native Synapse, MAS, etc.) because Synapse handles token validation
internally.

Implementation uses nginx's auth_request module with an njs script because:
- The whoami lookup requires an async HTTP subrequest (ngx.fetch)
- js_set handlers must return synchronously and don't support async operations
- auth_request allows the async lookup to complete, then captures the result
  via response headers into nginx variables

The njs script:
- Extracts access tokens from Authorization header or query parameter
- Calls Synapse's whoami endpoint to resolve token -> username
- Caches results in a shared memory zone to minimize latency
- Returns the username via a `X-User-Identifier` header

The username is then used by nginx's upstream hash directive for consistent
worker selection. This leverages nginx's built-in health checking and failover.
This commit is contained in:
Slavi Pantaleev
2026-02-04 03:14:47 +02:00
parent 81f815d19b
commit 5cc69ca7eb
6 changed files with 368 additions and 13 deletions

View File

@@ -41,20 +41,48 @@
{% endfor %}
{% endmacro %}
{% macro render_locations_to_upstream_with_whoami_sync_worker_router(locations, upstream_name) %}
{% for location in locations %}
location ~ {{ location }} {
# Use auth_request to call the whoami sync worker router.
# The handler resolves the access token to a user identifier and returns it
# in the X-User-Identifier header, which is then used for upstream hashing.
auth_request /_whoami_sync_worker_router;
auth_request_set $user_identifier $sent_http_x_user_identifier;
{% if matrix_synapse_reverse_proxy_companion_whoami_sync_worker_router_debug_headers_enabled %}
add_header X-Sync-Worker-Router-User-Identifier $user_identifier always;
add_header X-Sync-Worker-Router-Upstream $upstream_addr always;
{% endif %}
proxy_pass http://{{ upstream_name }}$request_uri;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
{% endfor %}
{% endmacro %}
{% if matrix_synapse_reverse_proxy_companion_synapse_workers_enabled %}
# Access token to user identifier mapping logic.
# This is used for sticky routing to ensure requests from the same user are routed to the same worker.
{% if not matrix_synapse_reverse_proxy_companion_whoami_sync_worker_router_enabled %}
# Extracts the base64-encoded localpart from native Synapse access tokens.
# Native Synapse tokens have the format: syt_<base64 localpart>_<random>_<crc>
# See: https://github.com/element-hq/synapse/blob/1bddd25a85d82b2ef4a2a42f6ecd476108d7dd96/synapse/handlers/auth.py#L1448-L1459
# Maps from https://tcpipuk.github.io/synapse/deployment/nginx.html#mapsconf
# Client username from access token
# Note: This only works with native Synapse tokens, not with MAS or other auth systems.
map $arg_access_token $accesstoken_from_urlparam {
default $arg_access_token;
"~syt_(?<username>.*?)_.*" $username;
default $arg_access_token;
"~syt_(?<b64localpart>.*?)_.*" $b64localpart;
}
# Client username from MXID
map $http_authorization $mxid_localpart {
default $http_authorization;
"~Bearer syt_(?<username>.*?)_.*" $username;
"" $accesstoken_from_urlparam;
map $http_authorization $user_identifier {
default $http_authorization;
"~Bearer syt_(?<b64localpart>.*?)_.*" $b64localpart;
"" $accesstoken_from_urlparam;
}
{% endif %}
# Whether to upgrade HTTP connection
map $http_upgrade $connection_upgrade {
default upgrade;
@@ -76,7 +104,7 @@ map $request_uri $room_name {
{% endif %}
{% if sync_workers | length > 0 %}
{{- render_worker_upstream('sync_workers_upstream', sync_workers, 'hash $mxid_localpart consistent;') }}
{{- render_worker_upstream('sync_workers_upstream', sync_workers, 'hash $user_identifier consistent;') }}
{% endif %}
{% if client_reader_workers | length > 0 %}
@@ -134,6 +162,17 @@ server {
proxy_max_temp_file_size 0;
proxy_set_header Host $host;
{% if matrix_synapse_reverse_proxy_companion_whoami_sync_worker_router_enabled %}
# Internal location for whoami-based sync worker routing.
# This is called via auth_request from sync worker locations.
# The njs handler calls the whoami endpoint to resolve access tokens to usernames,
# then returns the username in the X-User-Identifier header for upstream hashing.
location = /_whoami_sync_worker_router {
internal;
js_content whoami_sync_worker_router.handleAuthRequest;
}
{% endif %}
{% if matrix_synapse_reverse_proxy_companion_synapse_workers_enabled %}
# Client-server overrides — These locations must go to the main Synapse process
location ~ {{ matrix_synapse_reverse_proxy_companion_client_server_main_override_locations_regex }} {
@@ -207,7 +246,11 @@ server {
# sync workers
# https://tcpipuk.github.io/synapse/deployment/workers.html
# https://tcpipuk.github.io/synapse/deployment/nginx.html#locationsconf
{% if matrix_synapse_reverse_proxy_companion_whoami_sync_worker_router_enabled %}
{{ render_locations_to_upstream_with_whoami_sync_worker_router(matrix_synapse_reverse_proxy_companion_synapse_sync_worker_client_server_locations, 'sync_workers_upstream') }}
{% else %}
{{ render_locations_to_upstream(matrix_synapse_reverse_proxy_companion_synapse_sync_worker_client_server_locations, 'sync_workers_upstream') }}
{% endif %}
{% endif %}
{% if client_reader_workers | length > 0 %}