diff --git a/actions/matrixsynapse b/actions/matrixsynapse
index 5dc5b1bb4..4b7acd824 100755
--- a/actions/matrixsynapse
+++ b/actions/matrixsynapse
@@ -5,7 +5,10 @@ Configuration helper for Matrix-Synapse server.
"""
import argparse
+import json
+import os
import pathlib
+import sys
import yaml
@@ -14,6 +17,9 @@ from plinth.modules.matrixsynapse import (LISTENERS_CONF_PATH, ORIG_CONF_PATH,
REGISTRATION_CONF_PATH,
STATIC_CONF_PATH)
+TURN_CONF_PATH = '/etc/matrix-synapse/conf.d/freedombox-turn.yaml'
+OVERRIDDEN_TURN_CONF_PATH = '/etc/matrix-synapse/conf.d/turn.yaml'
+
STATIC_CONFIG = {
'max_upload_size':
'100M',
@@ -53,6 +59,14 @@ def parse_arguments():
'move-old-conf',
help='Move old configuration file to backup before reinstall')
+ turn = subparsers.add_parser(
+ 'configure-turn',
+ help='Configure a TURN server for use with Matrix Synapse')
+ turn.add_argument(
+ '--managed', required=False, default=False, action='store_true',
+ help='Whether configuration is provided by user or auto-managed by '
+ 'FreedomBox')
+
subparsers.required = True
return parser.parse_args()
@@ -112,8 +126,7 @@ def subcommand_public_registration(argument):
with open(REGISTRATION_CONF_PATH, 'w') as reg_conf_file:
yaml.dump(config, reg_conf_file)
- if action_utils.service_is_running('matrix-synapse'):
- action_utils.service_restart('matrix-synapse')
+ action_utils.service_try_restart('matrix-synapse')
def subcommand_move_old_conf(_arguments):
@@ -124,6 +137,39 @@ def subcommand_move_old_conf(_arguments):
conf_file.replace(backup_file)
+def _set_turn_config(conf_file):
+ turn_server_config = json.loads(''.join(sys.stdin))
+
+ if not turn_server_config['uris']:
+ # No valid configuration, remove the configuration file
+ try:
+ os.remove(conf_file)
+ except FileNotFoundError:
+ pass
+
+ return
+
+ config = {
+ 'turn_uris': turn_server_config['uris'],
+ 'turn_shared_secret': turn_server_config['shared_secret'],
+ 'turn_user_lifetime': 86400000,
+ 'turn_allow_guests': True
+ }
+
+ with open(conf_file, 'w+') as turn_config:
+ yaml.dump(config, turn_config)
+
+
+def subcommand_configure_turn(arguments):
+ """Set parameters for the STUN/TURN server to use with Matrix Synapse."""
+ if arguments.managed:
+ _set_turn_config(TURN_CONF_PATH)
+ else:
+ _set_turn_config(OVERRIDDEN_TURN_CONF_PATH)
+
+ action_utils.service_try_restart('matrix-synapse')
+
+
def main():
arguments = parse_arguments()
sub_command = arguments.subcommand.replace('-', '_')
diff --git a/plinth/modules/matrixsynapse/__init__.py b/plinth/modules/matrixsynapse/__init__.py
index 3445ee0b6..27063e24b 100644
--- a/plinth/modules/matrixsynapse/__init__.py
+++ b/plinth/modules/matrixsynapse/__init__.py
@@ -6,6 +6,7 @@ FreedomBox app to configure matrix-synapse server.
import logging
import os
import pathlib
+from typing import List
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
@@ -17,12 +18,14 @@ from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
+from plinth.modules.coturn.components import TurnConfiguration, TurnConsumer
from plinth.modules.firewall.components import Firewall
from plinth.modules.letsencrypt.components import LetsEncrypt
+from plinth.utils import is_non_empty_file
from . import manifest
-version = 6
+version = 7
managed_services = ['matrix-synapse']
@@ -44,14 +47,19 @@ _description = [
'Element client is recommended.')
]
+depends = ['coturn']
+
logger = logging.getLogger(__name__)
-SERVER_NAME_PATH = "/etc/matrix-synapse/conf.d/server_name.yaml"
+CONF_DIR = "/etc/matrix-synapse/conf.d/"
+
ORIG_CONF_PATH = '/etc/matrix-synapse/homeserver.yaml'
-STATIC_CONF_PATH = '/etc/matrix-synapse/conf.d/freedombox-static.yaml'
-LISTENERS_CONF_PATH = '/etc/matrix-synapse/conf.d/freedombox-listeners.yaml'
-REGISTRATION_CONF_PATH = \
- '/etc/matrix-synapse/conf.d/freedombox-registration.yaml'
+SERVER_NAME_PATH = CONF_DIR + 'server_name.yaml'
+STATIC_CONF_PATH = CONF_DIR + 'freedombox-static.yaml'
+LISTENERS_CONF_PATH = CONF_DIR + 'freedombox-listeners.yaml'
+REGISTRATION_CONF_PATH = CONF_DIR + 'freedombox-registration.yaml'
+TURN_CONF_PATH = CONF_DIR + 'freedombox-turn.yaml'
+OVERRIDDEN_TURN_CONF_PATH = CONF_DIR + 'turn.yaml'
app = None
@@ -110,6 +118,17 @@ class MatrixSynapseApp(app_module.App):
**manifest.backup)
self.add(backup_restore)
+ turn = MatrixSynapseTurnConsumer('turn-matrixsynapse')
+ self.add(turn)
+
+
+class MatrixSynapseTurnConsumer(TurnConsumer):
+ """Component to manage Coturn configuration for Matrix Synapse."""
+
+ def on_config_change(self, config: TurnConfiguration):
+ """Add or update STUN/TURN configuration."""
+ update_turn_configuration(config)
+
def setup(helper, old_version=None):
"""Install and configure the module."""
@@ -125,6 +144,10 @@ def setup(helper, old_version=None):
app.get_component('letsencrypt-matrixsynapse').setup_certificates()
+ # Configure STUN/TURN only if there's a valid TLS domain set for Coturn
+ config = app.get_component('turn-matrixsynapse').get_configuration()
+ update_turn_configuration(config, force=True)
+
def upgrade(helper):
"""Upgrade matrix-synapse configuration to avoid conffile prompt."""
@@ -171,7 +194,21 @@ def get_configured_domain_name():
return config['server_name']
-def get_public_registration_status():
+def get_turn_configuration() -> (List[str], str, bool):
+ """Return TurnConfiguration if setup else empty."""
+ for file_path, managed in ((OVERRIDDEN_TURN_CONF_PATH, False),
+ (TURN_CONF_PATH, True)):
+ if is_non_empty_file(file_path):
+ with open(file_path) as config_file:
+ config, _, _ = load_yaml_guess_indent(config_file)
+ return (TurnConfiguration(None, config['turn_uris'],
+ config['turn_shared_secret']),
+ managed)
+
+ return (TurnConfiguration(), True)
+
+
+def get_public_registration_status() -> bool:
"""Return whether public registration is enabled."""
output = actions.superuser_run('matrixsynapse',
['public-registration', 'status'])
@@ -185,3 +222,16 @@ def get_certificate_status():
return 'no-domains'
return list(status.values())[0]
+
+
+def update_turn_configuration(config: TurnConfiguration, managed=True,
+ force=False):
+ """Update the STUN/TURN server configuration."""
+ setup_helper = globals()['setup_helper']
+ if not force and setup_helper.get_state() == 'needs-setup':
+ return
+
+ params = ['configure-turn']
+ params += ['--managed'] if managed else []
+ actions.superuser_run('matrixsynapse', params,
+ input=config.to_json().encode())
diff --git a/plinth/modules/matrixsynapse/forms.py b/plinth/modules/matrixsynapse/forms.py
index 67ae1b47e..ea36735b6 100644
--- a/plinth/modules/matrixsynapse/forms.py
+++ b/plinth/modules/matrixsynapse/forms.py
@@ -4,8 +4,11 @@ Forms for the Matrix Synapse module.
"""
from django import forms
+from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
+from plinth.utils import format_lazy
+
class MatrixSynapseForm(forms.Form):
enable_public_registration = forms.BooleanField(
@@ -13,3 +16,28 @@ class MatrixSynapseForm(forms.Form):
'Enabling public registration means that anyone on the Internet '
'can register a new account on your Matrix server. Disable this '
'if you only want existing users to be able to use it.'))
+
+ enable_managed_turn = forms.BooleanField(
+ label=_('Automatically manage audio/video call setup'), required=False,
+ help_text=format_lazy(
+ _('Configures the local coturn app as '
+ 'the STUN/TURN server for Matrix Synapse. Disable this if you '
+ 'want to use a different STUN/TURN server.'),
+ coturn_url=reverse_lazy('coturn:index')))
+
+ # STUN/TURN server setup
+ turn_uris = forms.CharField(
+ label=_('STUN/TURN Server URIs'), required=False, strip=True,
+ widget=forms.Textarea(attrs={'rows': 4}),
+ help_text=_('List of public URIs of the STUN/TURN server, one on each '
+ 'line.'))
+
+ shared_secret = forms.CharField(
+ label=_('Shared Authentication Secret'), required=False, strip=True,
+ help_text=_('Shared secret used to compute passwords for the '
+ 'TURN server.'))
+
+ def clean_turn_uris(self):
+ """Normalize newlines in URIs."""
+ data = self.cleaned_data['turn_uris']
+ return '\n'.join([uri.strip() for uri in data.splitlines()])
diff --git a/plinth/modules/matrixsynapse/static/matrixsynapse.js b/plinth/modules/matrixsynapse/static/matrixsynapse.js
new file mode 100644
index 000000000..6822a474e
--- /dev/null
+++ b/plinth/modules/matrixsynapse/static/matrixsynapse.js
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+/**
+ * @licstart The following is the entire license notice for the JavaScript
+ * code in this page.
+ *
+ * This file is part of FreedomBox.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * @licend The above is the entire license notice for the JavaScript code
+ * in this page.
+ */
+
+jQuery(function($) {
+ $('#id_enable_managed_turn').change(function() {
+ if($(this).prop('checked')) {
+ $('#id_turn_uris').closest('.form-group').hide();
+ $('#id_shared_secret').closest('.form-group').hide();
+ } else {
+ $('#id_turn_uris').closest('.form-group').show();
+ $('#id_shared_secret').closest('.form-group').show();
+ }
+ }).change();
+});
diff --git a/plinth/modules/matrixsynapse/templates/matrix-synapse.html b/plinth/modules/matrixsynapse/templates/matrix-synapse.html
index 8664e4c00..082303ada 100644
--- a/plinth/modules/matrixsynapse/templates/matrix-synapse.html
+++ b/plinth/modules/matrixsynapse/templates/matrix-synapse.html
@@ -4,6 +4,7 @@
{% endcomment %}
{% load i18n %}
+{% load static %}
{% block description %}
{% for paragraph in description %}
@@ -36,3 +37,8 @@
{% endif %}
{% endblock %}
+
+{% block page_js %}
+
+{% endblock %}
diff --git a/plinth/modules/matrixsynapse/tests/conftest.py b/plinth/modules/matrixsynapse/tests/conftest.py
new file mode 100644
index 000000000..d9aa4eafb
--- /dev/null
+++ b/plinth/modules/matrixsynapse/tests/conftest.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Common test fixtures for Matrix Synapse.
+"""
+
+import importlib
+import pathlib
+import types
+from unittest.mock import patch
+
+import pytest
+
+current_directory = pathlib.Path(__file__).parent
+
+
+def _load_actions_module():
+ actions_file_path = str(current_directory / '..' / '..' / '..' / '..' /
+ 'actions' / 'matrixsynapse')
+ loader = importlib.machinery.SourceFileLoader('matrixsynapse',
+ actions_file_path)
+ module = types.ModuleType(loader.name)
+ loader.exec_module(module)
+ return module
+
+
+actions = _load_actions_module()
+
+
+@pytest.fixture(name='managed_turn_conf_file')
+def fixture_managed_turn_conf_file(tmp_path):
+ """Returns a dummy TURN configuration file."""
+ conf_file = tmp_path / 'freedombox-turn.yaml'
+ return str(conf_file)
+
+
+@pytest.fixture(name='overridden_turn_conf_file')
+def fixture_overridden_turn_conf_file(tmp_path):
+ """Returns a dummy TURN configuration file."""
+ conf_file = tmp_path / 'turn.yaml'
+ return str(conf_file)
+
+
+@pytest.fixture(name='call_action')
+def fixture_call_action(capsys, managed_turn_conf_file,
+ overridden_turn_conf_file):
+ """Run actions with custom root path."""
+
+ def _call_action(module_name, args, **kwargs):
+ actions.TURN_CONF_PATH = managed_turn_conf_file
+ actions.OVERRIDDEN_TURN_CONF_PATH = overridden_turn_conf_file
+ with patch('argparse._sys.argv', [module_name] +
+ args), patch('plinth.action_utils.service_try_restart'):
+ actions.main()
+ captured = capsys.readouterr()
+ return captured.out
+
+ return _call_action
diff --git a/plinth/modules/matrixsynapse/tests/test_turn_config.py b/plinth/modules/matrixsynapse/tests/test_turn_config.py
new file mode 100644
index 000000000..bda46c72f
--- /dev/null
+++ b/plinth/modules/matrixsynapse/tests/test_turn_config.py
@@ -0,0 +1,89 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Test module for Matrix Synapse STUN/TURN configuration.
+"""
+
+from unittest.mock import patch
+
+import pytest
+
+from plinth.modules import matrixsynapse
+from plinth.modules.coturn.components import TurnConfiguration
+
+
+@pytest.fixture(name='test_configuration', autouse=True)
+def fixture_test_configuration(call_action, managed_turn_conf_file,
+ overridden_turn_conf_file):
+ """Use a separate Matrix Synapse configuration for tests.
+
+ Overrides TURN configuration files and patches actions.superuser_run
+ with the fixture call_action
+ """
+ with patch('plinth.modules.matrixsynapse.TURN_CONF_PATH',
+ managed_turn_conf_file), \
+ patch('plinth.modules.matrixsynapse.OVERRIDDEN_TURN_CONF_PATH',
+ overridden_turn_conf_file), \
+ patch('plinth.modules.matrixsynapse.is_setup', return_value=True), \
+ patch('plinth.actions.superuser_run', call_action):
+ yield
+
+
+coturn_configuration = TurnConfiguration(
+ 'freedombox.local', [],
+ 'aiP02OAGyOlj6WGuCyqj7iaOsbkC7BUeKvKzhAsTZ8MEwMd3yTwpr2uvbOxgWe51')
+
+overridden_configuration = TurnConfiguration(
+ 'public.coturn.site', [],
+ 'BUeKvKzhAsTZ8MEwMd3yTwpr2uvbOxgWe51aiP02OAGyOlj6WGuCyqj7iaOsbkC7')
+
+updated_coturn_configuration = TurnConfiguration(
+ 'my.freedombox.rocks', [],
+ 'aiP02OsbkC7BUeKvKzhAsTZ8MEwMd3yTwpr2uvbOxgWe51AGyOlj6WGuCyqj7iaO')
+
+
+def _set_managed_configuration(monkeypatch, config=coturn_configuration):
+ monkeypatch.setattr('sys.stdin', config.to_json())
+ matrixsynapse.update_turn_configuration(config)
+
+
+def _set_overridden_configuration(monkeypatch,
+ config=overridden_configuration):
+ monkeypatch.setattr('sys.stdin', config.to_json())
+ matrixsynapse.update_turn_configuration(config, managed=False)
+
+
+def _assert_conf(expected_configuration, expected_managed):
+ """Assert that matrix synapse TURN configuration is as expected."""
+ config, managed = matrixsynapse.get_turn_configuration()
+ assert config.uris == expected_configuration.uris
+ assert config.shared_secret == expected_configuration.shared_secret
+ assert managed == expected_managed
+
+
+def test_managed_turn_server_configuration(monkeypatch):
+ """Test setting and getting managed TURN server configuration."""
+ _set_managed_configuration(monkeypatch)
+ _assert_conf(coturn_configuration, True)
+
+
+def test_overridden_turn_server_configuration(monkeypatch):
+ """Test setting and getting overridden TURN sever configuration."""
+ _set_overridden_configuration(monkeypatch)
+ _assert_conf(overridden_configuration, False)
+
+
+def test_revert_to_managed_turn_server_configuration(monkeypatch):
+ """Test setting and getting overridden TURN sever configuration."""
+ # Had to do all 3 operations because all fixtures were function-scoped
+ _set_managed_configuration(monkeypatch)
+ _set_overridden_configuration(monkeypatch)
+ _set_overridden_configuration(monkeypatch, TurnConfiguration())
+ _assert_conf(coturn_configuration, True)
+
+
+def test_coturn_configuration_update_after_admin_override(monkeypatch):
+ """Test that overridden conf prevails even if managed conf is updated."""
+ _set_managed_configuration(monkeypatch)
+ _set_overridden_configuration(monkeypatch)
+ _set_managed_configuration(monkeypatch, updated_coturn_configuration)
+ _assert_conf(overridden_configuration, False)
diff --git a/plinth/modules/matrixsynapse/views.py b/plinth/modules/matrixsynapse/views.py
index f8746b342..892097850 100644
--- a/plinth/modules/matrixsynapse/views.py
+++ b/plinth/modules/matrixsynapse/views.py
@@ -12,9 +12,10 @@ from django.views.generic import FormView
from plinth import actions
from plinth.forms import DomainSelectionForm
from plinth.modules import matrixsynapse, names
+from plinth.modules.coturn.components import TurnConfiguration
from plinth.views import AppView
-from . import get_public_registration_status
+from . import get_public_registration_status, get_turn_configuration
from .forms import MatrixSynapseForm
@@ -64,29 +65,62 @@ class MatrixSynapseAppView(AppView):
def get_initial(self):
"""Return the values to fill in the form."""
initial = super().get_initial()
+ config, managed = get_turn_configuration()
initial.update({
'enable_public_registration': get_public_registration_status(),
+ 'enable_managed_turn': managed,
+ 'turn_uris': '\n'.join(config.uris),
+ 'shared_secret': config.shared_secret
})
return initial
+ @staticmethod
+ def _handle_public_registrations(new_config):
+ if new_config['enable_public_registration']:
+ actions.superuser_run('matrixsynapse',
+ ['public-registration', 'enable'])
+ else:
+ actions.superuser_run('matrixsynapse',
+ ['public-registration', 'disable'])
+
+ @staticmethod
+ def _handle_turn_configuration(old_config, new_config):
+ if not new_config['enable_managed_turn']:
+ new_turn_uris = new_config['turn_uris'].splitlines()
+ new_shared_secret = new_config['shared_secret']
+
+ turn_config_changed = \
+ old_config['turn_uris'] != new_turn_uris or \
+ old_config['shared_secret'] != new_shared_secret
+
+ if turn_config_changed:
+ matrixsynapse.update_turn_configuration(
+ TurnConfiguration(None, new_turn_uris, new_shared_secret),
+ managed=False)
+ else:
+ # Remove overridden turn configuration
+ matrixsynapse.update_turn_configuration(TurnConfiguration(),
+ managed=False)
+
def form_valid(self, form):
"""Handle valid form submission."""
old_config = self.get_initial()
new_config = form.cleaned_data
- pubreg_same = old_config['enable_public_registration'] == \
- new_config['enable_public_registration']
- if not pubreg_same:
- # note action public-registration restarts, if running now
- if new_config['enable_public_registration']:
- actions.superuser_run('matrixsynapse',
- ['public-registration', 'enable'])
- messages.success(self.request,
- _('Public registration enabled'))
- else:
- actions.superuser_run('matrixsynapse',
- ['public-registration', 'disable'])
- messages.success(self.request,
- _('Public registration disabled'))
+ def changed(prop):
+ return old_config[prop] != new_config[prop]
+
+ is_changed = False
+ if changed('enable_public_registration'):
+ self._handle_public_registrations(new_config)
+ is_changed = True
+
+ if changed('enable_managed_turn') or changed('turn_uris') or \
+ changed('shared_secret'):
+ self._handle_turn_configuration(old_config, new_config)
+ is_changed = True
+
+ if is_changed:
+ messages.success(self.request, _('Configuration updated'))
return super().form_valid(form)