diff --git a/.github/renovate.json b/.github/renovate.json index 9ebaea40a..d76fe517f 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -156,7 +156,8 @@ "roles/custom/matrix-livekit-jwt-service/defaults/main.yml", "roles/custom/matrix-static-files/defaults/main.yml", "roles/custom/matrix-synapse/defaults/main.yml", - "roles/custom/matrix-synapse-auto-compressor/defaults/main.yml" + "roles/custom/matrix-synapse-auto-compressor/defaults/main.yml", + "roles/custom/matrix-tuwunel/defaults/main.yml" ], "matchUpdateTypes": [ "patch" diff --git a/docs/molecule-testing.md b/docs/molecule-testing.md index 6253d9731..e4acbde22 100644 --- a/docs/molecule-testing.md +++ b/docs/molecule-testing.md @@ -160,7 +160,7 @@ a fresh install. ## Databases -Scenarios for roles that have a database run against **Postgres**, not sqlite. +Scenarios for roles that support an external SQL database run against **Postgres**, not sqlite. That is what `group_vars/matrix_servers` selects whenever postgres is enabled, which is the default, so it is what essentially every deployment runs. sqlite is a path almost nobody is on: @@ -180,6 +180,8 @@ cope with it. Prefer asserting on the schema the component created over a file on disk: tables can only appear once it has resolved the hostname, authenticated, and run its migrations. +Tuwunel uses embedded RocksDB instead. Its scenario exercises token-protected registration and authenticated Matrix APIs against that database, and checks that the configured listener, server name, display-name suffix, upload limit, and client discovery URL reach the running homeserver. + ## Reclaiming the disk space `just molecule-clean` removes what the runs leave under `var/`. diff --git a/roles/custom/matrix-tuwunel/molecule/default/converge.yml b/roles/custom/matrix-tuwunel/molecule/default/converge.yml new file mode 100644 index 000000000..55edbd756 --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/converge.yml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Include roles for Tuwunel Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: true + tasks: + - name: Include base roles for Tuwunel Molecule tests + ansible.builtin.include_role: + name: "{{ role_name }}" + public: true + loop: + - com.devture.ansible.role.playbook_help + - com.devture.ansible.role.systemd_docker_base + loop_control: + loop_var: role_name + + # Include setup directly: main.yml also carries self-check and migration actions. + - name: Include Tuwunel setup task files + ansible.builtin.include_role: + name: "custom/{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}" + public: true + tasks_from: "{{ role_task_file }}" + loop: + - validate_config.yml + - install.yml + loop_control: + loop_var: role_task_file + +- name: Ensure Tuwunel is started + hosts: all + become: true + gather_facts: false + tasks: + - name: Ensure systemd daemon is reloaded + ansible.builtin.systemd_service: + daemon_reload: true + + - name: Ensure the Tuwunel systemd service is started + ansible.builtin.systemd_service: + name: matrix-tuwunel.service + state: started diff --git a/roles/custom/matrix-tuwunel/molecule/default/molecule.yml b/roles/custom/matrix-tuwunel/molecule/default/molecule.yml new file mode 100644 index 000000000..8409441ca --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/molecule.yml @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +dependency: + name: galaxy + options: + requirements-file: requirements.yml + force: true +driver: + name: docker +platforms: + - name: tuwunel-${MOLECULE_DISTRO:-ubuntu2604}-default + image: "geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2604}-ansible:latest" + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + pre_build_image: true +provisioner: + name: ansible + config_options: + defaults: + callback_result_format: yaml + inventory: + group_vars: + all: + matrix_tuwunel_hostname: matrix.example.com + matrix_tuwunel_container_network: tuwunel-molecule + matrix_tuwunel_config_server_name: example.com + matrix_tuwunel_config_port_number: 16167 + matrix_tuwunel_systemd_service_post_start_delay_seconds: 0 + + # Exercise real local registration against the embedded RocksDB database. + # These credentials never authenticate to an external service. + matrix_tuwunel_config_allow_registration: true + matrix_tuwunel_config_registration_token: molecule-registration-7e290fc5 + matrix_tuwunel_config_new_user_displayname_suffix: '[Molecule]' + matrix_tuwunel_config_grant_admin_to_first_user: false + matrix_tuwunel_config_allow_federation: false + matrix_tuwunel_config_trusted_servers: [] + matrix_tuwunel_config_max_request_size: 17000000 + matrix_tuwunel_config_well_known_client: https://matrix.example.com + + # Keep resource use modest on the CI runner. RocksDB is Tuwunel's native + # database; no external database or homeserver fixture is needed. + matrix_tuwunel_config_rocksdb_parallelism_threads: 2 + matrix_tuwunel_config_db_cache_capacity_mb: 32 + matrix_tuwunel_config_db_write_buffer_capacity_mb: 16 + + # Labels are inspected on the running container; probes reach it directly + # over its own network without publishing a host port or running Traefik. + matrix_tuwunel_container_labels_traefik_entrypoints: web + + # Pin paths needed by verify.yml, but load the image version from the role. + matrix_tuwunel_base_path: /matrix/tuwunel-molecule + matrix_tuwunel_config_path: /matrix/tuwunel-molecule/config + matrix_tuwunel_data_path: /matrix/tuwunel-molecule/data + env: + # Workaround for https://github.com/ansible/molecule/issues/4391 + ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/../..:/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:${ANSIBLE_HOME:-~/.ansible}/roles +scenario: + test_sequence: + - dependency + - cleanup + - destroy + - syntax + - create + - prepare + - converge + - idempotence + - verify + - cleanup + - destroy +verifier: + name: ansible diff --git a/roles/custom/matrix-tuwunel/molecule/default/prepare.yml b/roles/custom/matrix-tuwunel/molecule/default/prepare.yml new file mode 100644 index 000000000..7ba662c56 --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/prepare.yml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare Tuwunel Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: true + tasks: + - name: Ensure apt cache is updated + ansible.builtin.apt: + update_cache: true + cache_valid_time: 600 + when: ansible_facts['os_family'] == 'Debian' + + - name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-requests + - fuse-overlayfs + state: present + + - name: Ensure Docker is installed + ansible.builtin.include_role: + name: ansible-role-docker + vars: + docker_daemon_options: + storage-driver: fuse-overlayfs + + # matrix-base creates this identity in a full deployment. The role's file tasks + # resolve owner and group by name, so the role-scoped test provides it directly. + - name: Ensure the matrix group exists + ansible.builtin.group: + name: "{{ matrix_group_name }}" + gid: "{{ matrix_user_gid }}" + state: present + + - name: Ensure the matrix user exists + ansible.builtin.user: + name: "{{ matrix_user_name }}" + uid: "{{ matrix_user_uid }}" + group: "{{ matrix_group_name }}" + create_home: false + system: true + state: present + + - name: Ensure the base data path exists + ansible.builtin.file: + path: "{{ matrix_base_data_path }}" + state: directory + owner: "{{ matrix_user_name }}" + group: "{{ matrix_group_name }}" + mode: "0750" diff --git a/roles/custom/matrix-tuwunel/molecule/default/probe.py b/roles/custom/matrix-tuwunel/molecule/default/probe.py new file mode 100644 index 000000000..7c4245262 --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/probe.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Exercise local Matrix registration and report observations to verify.yml.""" + +import json +import secrets +import sys +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +base_url, registration_token = sys.argv[1:] + + +def request(path, body=None, access_token=None): + headers = {"Content-Type": "application/json"} + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + data = json.dumps(body).encode() if body is not None else None + req = Request(base_url + path, data=data, headers=headers) + try: + response = urlopen(req, timeout=15) + except HTTPError as error: + response = error + with response: + return {"status": response.status, "body": json.load(response)} + + +# A fresh localpart lets `molecule verify` run repeatedly against the same database. +username = "alice_" + secrets.token_hex(6) +credentials = {"username": username, "password": secrets.token_urlsafe(24)} +result = {"username": username} +result["challenge"] = request("/_matrix/client/v3/register", credentials) +session = result["challenge"]["body"].get("session", "") +auth = {"type": "m.login.registration_token", "session": session} +result["bad_token"] = request( + "/_matrix/client/v3/register", + {**credentials, "auth": {**auth, "token": registration_token + "-invalid"}}, +) +registration = request( + "/_matrix/client/v3/register", + {**credentials, "auth": {**auth, "token": registration_token}}, +) +access_token = registration["body"].pop("access_token", None) +result["registration"] = registration +if access_token: + result["whoami"] = request("/_matrix/client/v3/account/whoami", access_token=access_token) + user_id = quote(registration["body"]["user_id"], safe="") + result["profile"] = request(f"/_matrix/client/v3/profile/{user_id}/displayname") + result["media"] = request("/_matrix/client/v1/media/config", access_token=access_token) +result["well_known"] = request("/.well-known/matrix/client") +print(json.dumps(result)) diff --git a/roles/custom/matrix-tuwunel/molecule/default/requirements.yml b/roles/custom/matrix-tuwunel/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-tuwunel/molecule/default/verify.yml b/roles/custom/matrix-tuwunel/molecule/default/verify.yml new file mode 100644 index 000000000..4e351a107 --- /dev/null +++ b/roles/custom/matrix-tuwunel/molecule/default/verify.yml @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Verify Tuwunel + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml" + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: false + vars: + matrix_tuwunel_molecule_config: "{{ (matrix_tuwunel_molecule_config_result.stdout | from_json).global }}" + matrix_tuwunel_molecule_probe: "{{ matrix_tuwunel_molecule_probe_result.stdout | from_json }}" + matrix_tuwunel_molecule_container: "{{ (matrix_tuwunel_molecule_inspect.stdout | from_json) | first }}" + + tasks: + - name: Load the role's defaults under a separate name + ansible.builtin.include_vars: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml" + name: matrix_tuwunel_role_defaults + + # No host ports: a helper reaches the service over its own container network. + - name: Wait for the Matrix versions endpoint on the configured listener + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_tuwunel_container_network }} + - "{{ molecule_shared_image_curl }}" + - --fail + - --silent + - --show-error + - --max-time + - '10' + - http://matrix-tuwunel:{{ matrix_tuwunel_config_port_number }}/_matrix/client/versions + register: matrix_tuwunel_molecule_versions + changed_when: false + until: matrix_tuwunel_molecule_versions.rc == 0 + retries: 24 + delay: 5 + + - name: Assert the non-default listener serves the Matrix API + ansible.builtin.assert: + that: + - "'v1.1' in (matrix_tuwunel_molecule_versions.stdout | from_json).versions" + + - name: Request the default listener which the scenario moved away from + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_tuwunel_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --output + - /dev/null + - --connect-timeout + - '2' + - --max-time + - '3' + - http://matrix-tuwunel:6167/_matrix/client/versions + register: matrix_tuwunel_molecule_default_listener + changed_when: false + failed_when: false + + - name: Assert the default listener refuses connections + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_default_listener.rc == 7 + + # This uses the real homeserver and its embedded RocksDB database. The helper + # only makes requests and records responses; the assertions remain below. + - name: Exercise token-protected registration and authenticated Matrix APIs + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --interactive + - --network={{ matrix_tuwunel_container_network }} + - "{{ molecule_shared_image_python }}" + - python + - '-' + - http://matrix-tuwunel:{{ matrix_tuwunel_config_port_number }} + - "{{ matrix_tuwunel_config_registration_token }}" + stdin: "{{ lookup('file', 'probe.py') }}" + register: matrix_tuwunel_molecule_probe_result + changed_when: false + + - name: Assert registration requires the configured token + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_probe.challenge.status == 401 + - "matrix_tuwunel_molecule_probe.challenge.body.flows == [{'stages': ['m.login.registration_token']}]" + - matrix_tuwunel_molecule_probe.bad_token.status == 401 + - matrix_tuwunel_molecule_probe.bad_token.body.errcode == 'M_FORBIDDEN' + + - name: Assert the configured token registers a user on the configured server + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_probe.registration.status == 200 + - matrix_tuwunel_molecule_probe.registration.body.user_id == '@' + matrix_tuwunel_molecule_probe.username + ':' + matrix_tuwunel_config_server_name + - matrix_tuwunel_molecule_probe.whoami.status == 200 + - matrix_tuwunel_molecule_probe.whoami.body.user_id == matrix_tuwunel_molecule_probe.registration.body.user_id + + - name: Assert the configured display-name suffix reaches new users + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_probe.profile.status == 200 + - matrix_tuwunel_molecule_probe.profile.body.displayname == matrix_tuwunel_molecule_probe.username + ' ' + matrix_tuwunel_config_new_user_displayname_suffix + + - name: Assert the authenticated media API advertises the configured upload limit + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_probe.media.status == 200 + - matrix_tuwunel_molecule_probe.media.body['m.upload.size'] == matrix_tuwunel_config_max_request_size + + - name: Assert client discovery serves the configured URL + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_probe.well_known.status == 200 + # Tuwunel normalizes the configured origin to a URL with a trailing slash. + - matrix_tuwunel_molecule_probe.well_known.body['m.homeserver'].base_url == matrix_tuwunel_config_well_known_client.rstrip('/') + '/' + + - name: Parse the rendered TOML configuration + ansible.builtin.command: + argv: + - python3 + - '-c' + - "import json, sys, tomllib; print(json.dumps(tomllib.load(open(sys.argv[1], 'rb'))))" + - "{{ matrix_tuwunel_config_path }}/tuwunel.toml" + register: matrix_tuwunel_molecule_config_result + changed_when: false + + - name: Assert the parsed TOML carries the scenario's settings + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_config.server_name == matrix_tuwunel_config_server_name + - matrix_tuwunel_molecule_config.port == matrix_tuwunel_config_port_number + - matrix_tuwunel_molecule_config.allow_registration + - matrix_tuwunel_molecule_config.registration_token == matrix_tuwunel_config_registration_token + - matrix_tuwunel_molecule_config.new_user_displayname_suffix == matrix_tuwunel_config_new_user_displayname_suffix + - matrix_tuwunel_molecule_config.max_request_size == matrix_tuwunel_config_max_request_size + - matrix_tuwunel_molecule_config.well_known.client == matrix_tuwunel_config_well_known_client + - not matrix_tuwunel_molecule_config.allow_federation + - matrix_tuwunel_molecule_config.trusted_servers == [] + - not matrix_tuwunel_molecule_config.grant_admin_to_first_user + + - name: Inspect the running Tuwunel container + ansible.builtin.command: + argv: [docker, container, inspect, matrix-tuwunel] + register: matrix_tuwunel_molecule_inspect + changed_when: false + + - name: Assert the running container uses the exact image pinned by the role + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_container.Config.Image == 'ghcr.io/matrix-construct/tuwunel:' + matrix_tuwunel_role_defaults.matrix_tuwunel_version + + - name: Assert the running container uses the playbook identity and isolation + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_container.Config.User == (matrix_user_uid | string) + ':' + (matrix_user_gid | string) + - matrix_tuwunel_molecule_container.HostConfig.ReadonlyRootfs + - "'ALL' in matrix_tuwunel_molecule_container.HostConfig.CapDrop" + + - name: Assert the role's configuration is mounted read-only and selected at runtime + ansible.builtin.assert: + that: + - "'TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml' in matrix_tuwunel_molecule_container.Config.Env" + - matrix_tuwunel_molecule_mount.Source == matrix_tuwunel_config_path + - not matrix_tuwunel_molecule_mount.RW + vars: + matrix_tuwunel_molecule_mount: >- + {{ matrix_tuwunel_molecule_container.Mounts + | selectattr('Destination', 'equalto', '/etc/tuwunel') | first }} + + - name: Assert the embedded database has the configured writable data mount + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_mount.Source == matrix_tuwunel_data_path + - matrix_tuwunel_molecule_mount.RW + vars: + matrix_tuwunel_molecule_mount: >- + {{ matrix_tuwunel_molecule_container.Mounts + | selectattr('Destination', 'equalto', '/var/lib/tuwunel') | first }} + + - name: Assert the container uses only the scenario network and publishes no ports + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_container.NetworkSettings.Networks.keys() | list == [matrix_tuwunel_container_network] + - matrix_tuwunel_molecule_container.HostConfig.PortBindings | default({}, true) == {} + + - name: Assert Traefik targets the configured listener and public hostname + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_labels['traefik.http.services.matrix-tuwunel.loadbalancer.server.port'] == matrix_tuwunel_config_port_number | string + - matrix_tuwunel_molecule_labels['traefik.http.routers.matrix-tuwunel-public-client-api.rule'] == 'Host(`' + matrix_tuwunel_hostname + '`) && PathPrefix(`/_matrix`)' + - matrix_tuwunel_molecule_labels['traefik.http.routers.matrix-tuwunel-public-tuwunel-api.rule'] == 'Host(`' + matrix_tuwunel_hostname + '`) && PathPrefix(`/_tuwunel`)' + - matrix_tuwunel_molecule_labels['traefik.http.routers.matrix-tuwunel-public-client-api.entrypoints'] == matrix_tuwunel_container_labels_traefik_entrypoints + vars: + matrix_tuwunel_molecule_labels: "{{ matrix_tuwunel_molecule_container.Config.Labels }}" + + # Check after the API probes: Restart=always alone can hide a crash loop. + - name: Read the Tuwunel service state after the probes + ansible.builtin.systemd_service: + name: matrix-tuwunel.service + register: matrix_tuwunel_molecule_service + + - name: Assert the service is active without automatic restarts + ansible.builtin.assert: + that: + - matrix_tuwunel_molecule_service.status.ActiveState == 'active' + - matrix_tuwunel_molecule_service.status.NRestarts is defined + - matrix_tuwunel_molecule_service.status.NRestarts | int == 0