feat(matrix): full onion support — OIDC backchannel, MXIDs, Tor-stable specs

Synapse on an onion node could never complete SSO: it has no Tor, so the
http://auth.<onion> issuer was unresolvable server-side (discovery/jwks/
token), and Synapse hard-rejects non-https issuers on top.

- runner/hostpin.{sh,service}: pin the onion auth host to the runner's real
  default gateway (node OpenResty) from /proc/net/route, Before=docker.service
  so nested containers see it from their first request; host-gateway is
  unusable in nested DinD (resolves to the inner docker0)
- Synapse bind-mounts the runner's /etc/hosts; token iss stays the onion
  issuer the browser uses
- bootstrap.service: PassEnvironment (systemd units do not inherit container
  env; MATRIX_MDAD_PLAYBOOK_TAGS only worked via its fallback default)
- vars.yml.j2: skip_verification on onion (Synapse's escape hatch for
  issuers without https; Tor provides transport security per RFC 7686)
- 02_render_inventory: drop the MDAD bootstrap marker + bounce the runner on
  inventory changes — mode=update reconfigures the nested stack at all now
- MATRIX_SERVER_NAME follows matrix_domain onto the onion so MXIDs built from
  it (DM spec, bridge configs) reference users that exist
- specs: shared Firefox onion fixture (16 files), gotoElementApp reload-retry
  against Element's Tor-triggered init race, seaweedfs spec gates on the
  filer frontend, env uses canonical_url consumer=

Verified in-container: SSO 302 to onion Keycloak, admin+biber OIDC login,
E2EE DM admin<->biber, CSP — all green over Tor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-12 10:56:13 +02:00
1 parent 5abfcfc6d6
commit 0a581f6622
26 files changed
+140 -34

No files matched your search

@@ -68,8 +68,10 @@ RUN ansible-galaxy collection install \
COPY daemon.json /etc/docker/daemon.json
COPY bootstrap.sh /usr/local/bin/matrix-mdad-bootstrap
COPY bootstrap.service /etc/systemd/system/matrix-mdad-bootstrap.service
RUN chmod 0755 /usr/local/bin/matrix-mdad-bootstrap \
&& systemctl enable containerd.service docker.service matrix-mdad-bootstrap.service
COPY hostpin.sh /usr/local/bin/matrix-mdad-hostpin
COPY hostpin.service /etc/systemd/system/matrix-mdad-hostpin.service
RUN chmod 0755 /usr/local/bin/matrix-mdad-bootstrap /usr/local/bin/matrix-mdad-hostpin \
&& systemctl enable containerd.service docker.service matrix-mdad-bootstrap.service matrix-mdad-hostpin.service
WORKDIR /mdad
STOPSIGNAL SIGRTMIN+3
@@ -6,6 +6,7 @@ Requires=docker.service
[Service]
Type=oneshot
Environment=PATH=/opt/ansible/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PassEnvironment=MATRIX_MDAD_PLAYBOOK_TAGS
ExecStartPre=/usr/bin/docker info
ExecStart=/usr/local/bin/matrix-mdad-bootstrap
RemainAfterExit=yes
@@ -0,0 +1,15 @@
[Unit]
Description=Pin the onion OIDC host to the node gateway before nested docker starts
Before=docker.service
After=network.target
[Service]
Type=oneshot
PassEnvironment=MATRIX_OIDC_ONION_HOST
ExecStart=/usr/local/bin/matrix-mdad-hostpin
RemainAfterExit=yes
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
if [ -z "${MATRIX_OIDC_ONION_HOST:-}" ]; then
echo ">>> matrix-mdad-hostpin: MATRIX_OIDC_ONION_HOST unset, nothing to pin"
exit 0
fi
_gw_hex="$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)"
if [ -z "${_gw_hex}" ]; then
echo "!!! matrix-mdad-hostpin: no default route found, skipping pin" >&2
exit 0
fi
_gw="$(printf '%d.%d.%d.%d' "0x${_gw_hex:6:2}" "0x${_gw_hex:4:2}" "0x${_gw_hex:2:2}" "0x${_gw_hex:0:2}")"
_hosts_tmp="$(mktemp)"
grep -v "[[:space:]]${MATRIX_OIDC_ONION_HOST}\$" /etc/hosts > "${_hosts_tmp}" || true
printf '%s\t%s\n' "${_gw}" "${MATRIX_OIDC_ONION_HOST}" >> "${_hosts_tmp}"
cat "${_hosts_tmp}" > /etc/hosts
rm -f "${_hosts_tmp}"
echo ">>> matrix-mdad-hostpin: pinned ${MATRIX_OIDC_ONION_HOST} -> ${_gw} (node OpenResty)"
@@ -1,4 +1,4 @@
const { expect } = require("@playwright/test");
const { expect } = require("./onion-test");
const { resolveTimeout } = require("./timeouts");
const { isServiceEnabled, skipUnlessServiceEnabled } = require("./service-gating");
const {
@@ -49,6 +49,30 @@ function attachDiagnostics(page) {
return { consoleErrors, pageErrors, cspRelated };
}
const ELEMENT_INIT_CRASH_RE = /Unexpected error preparing the app|Element is misconfigured/i;
async function gotoElementApp(page, url, readyLocator) {
const attempts = Number(process.env.PLAYWRIGHT_ELEMENT_BOOT_RETRIES) || 4;
const perAttempt = resolveTimeout(20_000);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (attempt === 1) {
await gotoOnion(page, url, { waitUntil: "domcontentloaded" });
} else {
await page.reload({ waitUntil: "domcontentloaded", timeout: resolveTimeout(90_000) });
}
const deadline = Date.now() + perAttempt;
while (Date.now() < deadline) {
if (await readyLocator.isVisible().catch(() => false)) return;
const bodyText = await page.locator("body").innerText().catch(() => "");
if (ELEMENT_INIT_CRASH_RE.test(bodyText)) break;
await page.waitForTimeout(2_000);
}
}
throw new Error(
`Element failed to mount at ${url} after ${attempts} attempt(s) (init crash / no ready signal)`
);
}
// Matrix Element SSO flow. Element stores the selected homeserver in
// sessionStorage during SSO initiation and reads it back when consuming the
// `?loginToken=…` the homeserver hands it after Keycloak auth. Hitting
@@ -60,8 +84,6 @@ function attachDiagnostics(page) {
async function signInViaElementOidc(page, username, password, personaLabel) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${elementBaseUrl}/#/login`);
const ssoButton = page
.locator(
[
@@ -76,6 +98,7 @@ async function signInViaElementOidc(page, username, password, personaLabel) {
.locator("button, a, div[role='button']")
.filter({ hasText: /single\s*sign[- ]*on|continue\s+with\s+(sso|oidc|keycloak|openid)|sign\s+in\s+with\s+(sso|oidc|keycloak|openid)/i })
.first();
await gotoElementApp(page, `${elementBaseUrl}/#/login`, ssoButton.or(ssoTextButton));
const candidate = (await ssoButton.isVisible({ timeout: resolveTimeout(15_000) }).catch(() => false))
? ssoButton
: ssoTextButton;
@@ -330,11 +353,10 @@ async function signInViaElementOidc(page, username, password, personaLabel) {
// indistinguishable from Element's side. Skip-verification + service-worker
// dismissal mirror the OIDC path; Synapse SSO consent does not apply.
async function signInViaElementPassword(page, username, password, personaLabel) {
await gotoOnion(page, `${elementBaseUrl}/#/login`);
const userField = page
.locator('input[name="username"], #mx_LoginForm_username, input[name="mxid"], input[data-testid="login_field_mx_id"]')
.first();
await gotoElementApp(page, `${elementBaseUrl}/#/login`, userField);
await expect(userField, `${personaLabel}: Element password login username field must appear`).toBeVisible({ timeout: resolveTimeout(30_000) });
await userField.fill(username);
@@ -453,6 +475,7 @@ module.exports = {
expectNoCspViolations,
installCspViolationObserver,
runGuestFlow,
gotoElementApp,
signInViaElementOidc,
signInViaElementPassword,
signInViaElement,
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./onion-test");
const { resolveTimeout } = require("./timeouts");
const shared = require("./_shared");
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./onion-test");
const BRIDGE_TO_BOT_LOCALPART = {
appservice_irc: "ircbot",
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./onion-test");
const { gotoOnion } = require("./personas");
exports.register = function (shared) {
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./onion-test");
const { resolveTimeout } = require("./timeouts");
exports.register = function (shared) {
@@ -62,9 +62,12 @@ exports.register = function (shared) {
// button we need lives inside the profile panel specifically (there is a
// separate "Send a Direct Message" button on the welcome screen that
// opens a search dialog rather than directly messaging biber).
await adminPage.goto(`${elementBaseUrl}/#/user/${encodeURIComponent(biberMatrixId)}`);
const profilePanel = adminPage.getByRole("complementary").filter({ hasText: biberMatrixId });
await shared.gotoElementApp(
adminPage,
`${elementBaseUrl}/#/user/${encodeURIComponent(biberMatrixId)}`,
profilePanel
);
await expect(profilePanel, "admin: biber profile panel must render").toBeVisible({ timeout: resolveTimeout(60_000) });
const profileSendMessageButton = profilePanel
@@ -1,4 +1,4 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./onion-test");
exports.register = function (shared) {
test("element-call: Element config.json advertises a video-call backend when conferencing is enabled", async ({ request }) => {
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./onion-test");
exports.register = function (shared) {
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./onion-test");
exports.register = function (shared) {
test("administrator: matrix element native password login (no oidc)", async ({ page }) => {
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./onion-test");
exports.register = function (shared) {
test("administrator: matrix element OIDC login and logout", async ({ page }) => {
@@ -1,4 +1,4 @@
const { test } = require("@playwright/test");
const { test } = require("./onion-test");
exports.register = function (shared) {
test("biber: matrix element OIDC login and logout", async ({ page }) => {
@@ -16,7 +16,7 @@
// signInViaElement, and the SEAWEEDFS_* keys consumed by
// runSeaweedfsStorageCheck.
const { test, expect } = require("@playwright/test");
const { test, expect } = require("./onion-test");
const { resolveTimeout } = require("./timeouts");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { runSeaweedfsStorageCheck } = require("./personas");
@@ -31,6 +31,7 @@ test.use({ ignoreHTTPSErrors: true });
test("seaweedfs: an uploaded Matrix avatar is stored in the SeaweedFS bucket", async ({ page, browser }) => {
skipUnlessServiceEnabled("seaweedfs");
skipUnlessServiceEnabled("seaweedfs_frontend");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"Matrix media is offloaded to SeaweedFS only in the ansible flavor; the compose flavor stores media on local disk, so the bucket never grows.",
+1
View File
@@ -143,3 +143,4 @@ tor:
bond: 1
enabled: "{{ 'svc-net-tor' in group_names }}"
shared: true # nocheck: dynamic-flag
exclusive: true
@@ -16,3 +16,17 @@
src: "flavor/ansible/vars.yml.j2"
dest: "{{ MATRIX_MDAD_DIR }}/inventory/host_vars/{{ MATRIX_SYNAPSE_DOMAIN }}/vars.yml"
mode: "0644"
register: _matrix_mdad_vars
- name: "reconfigure matrix-mdad on inventory change"
ansible.builtin.shell: |
set -euo pipefail
_mp="$(docker volume inspect -f '{% raw %}{{ .Mountpoint }}{% endraw %}' matrix_matrix_mdad_state)"
rm -f "${_mp}/bootstrap.done"
if docker ps -q -f name=^matrix-mdad$ | grep -q .; then
docker restart matrix-mdad
fi
args:
executable: /bin/bash
become: true
when: _matrix_mdad_vars is changed
@@ -14,6 +14,9 @@
stop_signal: SIGRTMIN+3
environment:
MATRIX_MDAD_PLAYBOOK_TAGS: "{{ MATRIX_MDAD_PLAYBOOK_TAGS }}"
{% if MATRIX_OIDC_ENABLED and MATRIX_OIDC_HOST_IS_ONION %}
MATRIX_OIDC_ONION_HOST: "{{ MATRIX_OIDC_HOST }}"
{% endif %}
{% set _objstore = lookup('objstore', application_id) %}
networks:
default: {}
@@ -1,5 +1,8 @@
---
matrix_domain: {{ DOMAIN_PRIMARY }}
{% set _matrix_onion_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %}
{% set _matrix_is_onion = (_matrix_onion_node and lookup('domain', application_id) is search('\.onion$')) %}
matrix_domain: {{ _matrix_onion_node if _matrix_is_onion else DOMAIN_PRIMARY }}
matrix_playbook_ssl_enabled: {{ 'false' if _matrix_is_onion else 'true' }}
matrix_homeserver_implementation: synapse
matrix_playbook_docker_installation_enabled: false
matrix_playbook_migration_validated_version: "{{ MATRIX_MDAD_MIGRATION_VALIDATED_VERSION }}"
@@ -59,11 +62,22 @@ jitsi_jicofo_container_network: host
matrix_livekit_jwt_service_container_port: 8030
{% set _synapse_extra_args = [] %}
{% if (TLS_MODE | lower) == 'self_signed' %}
{% set _ = _synapse_extra_args.extend([
'--mount type=bind,src=' ~ CA_TRUST.cert_host ~ ',dst=' ~ MATRIX_CA_MOUNT_PATH ~ ',ro',
'-e SSL_CERT_FILE=' ~ MATRIX_CA_MOUNT_PATH,
'-e REQUESTS_CA_BUNDLE=' ~ MATRIX_CA_MOUNT_PATH,
]) %}
{% endif %}
{% if MATRIX_OIDC_ENABLED and MATRIX_OIDC_HOST_IS_ONION %}
{% set _ = _synapse_extra_args.append('--mount type=bind,src=/etc/hosts,dst=/etc/hosts,ro') %}
{% endif %}
{% if _synapse_extra_args | length > 0 %}
matrix_synapse_container_extra_arguments:
- "--mount type=bind,src={{ CA_TRUST.cert_host }},dst={{ MATRIX_CA_MOUNT_PATH }},ro"
- "-e SSL_CERT_FILE={{ MATRIX_CA_MOUNT_PATH }}"
- "-e REQUESTS_CA_BUNDLE={{ MATRIX_CA_MOUNT_PATH }}"
{% for _arg in _synapse_extra_args %}
- "{{ _arg }}"
{% endfor %}
{% endif %}
{% if MATRIX_COTURN_SHARED %}
@@ -96,6 +110,9 @@ matrix_synapse_oidc_providers:
idp_name: "{{ OIDC.BUTTON_TEXT }}"
discover: true
issuer: "{{ OIDC.CLIENT.ISSUER_URL }}"
{% if _matrix_is_onion %}
skip_verification: true
{% endif %}
client_id: "{{ OIDC.CLIENT.ID }}"
client_secret: "{{ OIDC.CLIENT.SECRET }}"
scopes:
@@ -10,9 +10,9 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
BRIDGES_SERVICE_ENABLED={{ (MATRIX_BRIDGE.values() | list | select | list | length > 0) | string | lower }}
CANONICAL_DOMAIN={{ (lookup('applications', application_id).server.domains.canonical.element) | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domains', application_id).element | dotenv_quote }}
ELEMENT_BASE_URL={{ lookup('tls', lookup('config', application_id, 'server.domains.canonical.element'), 'url.base') | dotenv_quote }}
ELEMENT_BASE_URL={{ lookup('canonical_url', application_id, 'element') | dotenv_quote }}
ELEMENT_CALL_SERVICE_ENABLED={{ MATRIX_CONFERENCING_ELEMENT_CALL | string | lower }}
@@ -30,7 +30,7 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
# nocheck: playwright-service-gate — consumed via shared persona helpers (roles/test-e2e-playwright/files/personas/)
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
MATRIX_BASE_URL={{ lookup('tls', lookup('config', application_id, 'server.domains.canonical.synapse'), 'url.base') | dotenv_quote }}
MATRIX_BASE_URL={{ lookup('canonical_url', application_id, 'synapse') | dotenv_quote }}
MATRIX_FLAVOR={{ lookup('config', application_id, 'services.matrix.flavor') | dotenv_quote }}
@@ -46,10 +46,12 @@ SEAWEEDFS_ADMIN_USERNAME={{ MATRIX_ADMINISTRATOR_USERNAME | dotenv_quote }}
SEAWEEDFS_APP_BUCKET={{ lookup('objstore', application_id, 'bucket') | dotenv_quote }}
SEAWEEDFS_FILER_URL={{ ((lookup('tls', 'web-app-seaweedfs', 'protocols.web') ~ '://' ~ lookup('config', 'web-app-seaweedfs', 'server.domains.canonical.filer')) if (lookup('config', application_id, 'services.seaweedfs.enabled') | bool) else '') | dotenv_quote }}
SEAWEEDFS_FILER_URL={{ lookup('canonical_url', 'web-app-seaweedfs', 'filer', consumer=application_id) | dotenv_quote }}
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
SEAWEEDFS_FRONTEND_SERVICE_ENABLED={{ lookup('config', 'web-app-seaweedfs', 'services.frontend.enabled', false) | string | lower }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for bridge in MATRIX_BRIDGES | default([], true) %}
+5 -2
View File
@@ -3,7 +3,8 @@ application_id: "web-app-matrix"
location_upload: "~ ^/_matrix/media/v3/"
MATRIX_SERVER_NAME: "{{ lookup('config', application_id, 'services.matrix.server_name') }}"
MATRIX_ONION_NODE: "{{ lookup('config', 'svc-net-tor', 'services.tor.node', '') }}"
MATRIX_SERVER_NAME: "{{ MATRIX_ONION_NODE if (MATRIX_ONION_NODE and lookup('domain', application_id) is search('\\.onion$')) else lookup('config', application_id, 'services.matrix.server_name') }}"
MATRIX_ADMINISTRATOR_USERNAME: "{{ lookup('users', 'administrator').username }}"
MATRIX_ADMINISTRATOR_PASSWORD: "{{ lookup('users', 'administrator').password }}"
MATRIX_WELL_KNOWN_DIRECTORY: "{{ [ lookup('nginx','directories.data.well_known'), 'matrix' ] | path_join }}"
@@ -12,6 +13,8 @@ MATRIX_REGISTRATION_FILE_FOLDER: "/data/"
MATRIX_REGISTRATION_SHARED_SECRET: "{{ lookup('config', application_id, 'credentials.registration_shared_secret') }}"
MATRIX_FEDERATION_PORT: "{{ lookup('config', 'web-app-matrix', 'services.synapse.ports.public.federation') }}"
MATRIX_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
MATRIX_OIDC_HOST: "{{ lookup('domain', 'web-app-keycloak') }}"
MATRIX_OIDC_HOST_IS_ONION: "{{ MATRIX_OIDC_HOST is search('\\.onion$') }}"
MATRIX_BASE_URL: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ DOMAIN_PRIMARY }}"
MATRIX_SYNAPSE_VERSION: "{{ lookup('config', application_id, 'services.synapse.version') }}"
@@ -25,7 +28,7 @@ MATRIX_SYNAPSE_CONFIG_PATH_HOST: "{{ [ lookup('container', application_id, 'dire
MATRIX_SYNAPSE_CONFIG_PATH_CONTAINER: "/data/homeserver.yaml"
MATRIX_SYNAPSE_LOG_PATH_HOST: "{{ [ lookup('container', application_id, 'directories.config'), MATRIX_SYNAPSE_DOMAIN ~'.log.config' ] | path_join }}"
MATRIX_SYNAPSE_LOG_PATH_CONTAINER: "/data/{{ MATRIX_SYNAPSE_DOMAIN }}.log.config"
MATRIX_SYNAPSE_URL: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ MATRIX_SYNAPSE_DOMAIN }}"
MATRIX_SYNAPSE_URL: "{{ lookup('canonical_url', application_id, 'synapse') }}"
MATRIX_SYNAPSE_LISTEN_FLAGS: "{{ MATRIX_FEDERATION_PORT }} {{ 'ssl ' if (lookup('tls', application_id, 'enabled') | bool) else '' }} default_server"
MATRIX_ELEMENT_VERSION: "{{ lookup('config', application_id, 'services.element.version') }}"