From 7a30470cb53e5e82dd0c530318787eb100302e21 Mon Sep 17 00:00:00 2001
From: Joseph Nuthalapati
Date: Fri, 5 Mar 2021 22:08:56 +0530
Subject: [PATCH] ejabberd: STUN/TURN configuration
This implementation is very similar to that of Matrix Synapse with a lot
of code duplicated.
One major difference is that ejabberd doesn't have a conf.d/ directory.
So, the managed configuration and overridden configuration cannot be
cleanly separated.
Whether the configuration is managed or not is determined by the
presence of a file under `/etc/ejabberd`. Managed coturn configuration
isn't stored in ejabberd, since only one set of configuration can be
stored at a time. If the admin chooses to use the managed configuration,
the current coturn configuration is fetched and used to configure
ejabberd.
Fixes #1978
Signed-off-by: Joseph Nuthalapati
Reviewed-by: James Valleroy
---
actions/ejabberd | 99 +++++++-
plinth/modules/coturn/components.py | 11 +-
plinth/modules/ejabberd/__init__.py | 47 +++-
plinth/modules/ejabberd/forms.py | 26 ++
plinth/modules/ejabberd/static/ejabberd.js | 35 +++
.../modules/ejabberd/templates/ejabberd.html | 8 +-
plinth/modules/ejabberd/tests/conftest.py | 78 ++++++
.../ejabberd/tests/data/ejabberd.yml.example | 231 ++++++++++++++++++
.../ejabberd/tests/test_turn_config.py | 48 ++++
plinth/modules/ejabberd/views.py | 69 ++++--
10 files changed, 622 insertions(+), 30 deletions(-)
create mode 100644 plinth/modules/ejabberd/static/ejabberd.js
create mode 100644 plinth/modules/ejabberd/tests/conftest.py
create mode 100644 plinth/modules/ejabberd/tests/data/ejabberd.yml.example
create mode 100644 plinth/modules/ejabberd/tests/test_turn_config.py
diff --git a/actions/ejabberd b/actions/ejabberd
index dd9565bcf..cc492fffa 100755
--- a/actions/ejabberd
+++ b/actions/ejabberd
@@ -7,20 +7,22 @@ Configuration helper for the ejabberd service
import argparse
import json
import os
-import pathlib
+import re
import shutil
import socket
import subprocess
+import sys
from distutils.version import LooseVersion as LV
-
-from ruamel.yaml import YAML, scalarstring
+from pathlib import Path
from plinth import action_utils
+from ruamel.yaml import YAML, scalarstring
EJABBERD_CONFIG = '/etc/ejabberd/ejabberd.yml'
EJABBERD_BACKUP = '/var/log/ejabberd/ejabberd.dump'
EJABBERD_BACKUP_NEW = '/var/log/ejabberd/ejabberd_new.dump'
EJABBERD_ORIG_CERT = '/etc/ejabberd/ejabberd.pem'
+EJABBERD_MANAGED_COTURN = '/etc/ejabberd/freedombox_managed_coturn'
IQDISC_DEPRECATED_VERSION = LV('18.03')
MOD_IRC_DEPRECATED_VERSION = LV('18.06')
@@ -70,6 +72,18 @@ def parse_arguments():
help='Add a domain name to ejabberd')
add_domain.add_argument('--domainname', help='New domain name')
+ # Configure STUN/TURN server for use with ejabberd
+ turn = subparsers.add_parser(
+ 'configure-turn', help='Configure a TURN server for use with ejabberd')
+ turn.add_argument(
+ '--managed', required=False, default=False, action='store_true',
+ help='Whether configuration is provided by user or auto-managed by '
+ 'FreedomBox')
+
+ subparsers.add_parser(
+ 'get-turn-config',
+ help='Get the latest STUN/TURN configuration in JSON format')
+
# Switch/check Message Archive Management (MAM) in ejabberd config
help_MAM = 'Switch or check Message Archive Management (MAM).'
mam = subparsers.add_parser('mam', help=help_MAM)
@@ -107,6 +121,8 @@ def subcommand_setup(arguments):
for listen_port in conf['listen']:
if 'tls' in listen_port:
listen_port['tls'] = False
+ if 'use_turn' in listen_port:
+ conf['listen'].remove(listen_port) # Use coturn instead
conf['auth_method'] = 'ldap'
conf['ldap_servers'] = [scalarstring.DoubleQuotedScalarString('localhost')]
@@ -154,7 +170,7 @@ def upgrade_config(domain):
if listen_port['port'] == 5280:
listen_port['port'] = 5443
- cert_dir = pathlib.Path('/etc/ejabberd/letsencrypt') / domain
+ cert_dir = Path('/etc/ejabberd/letsencrypt') / domain
cert_file = str(cert_dir / 'ejabberd.pem')
cert_file = scalarstring.DoubleQuotedScalarString(cert_file)
conf['s2s_certfile'] = cert_file
@@ -285,6 +301,81 @@ def subcommand_mam(argument):
subprocess.call(['ejabberdctl', 'reload_config'])
+def _generate_service(uri: str) -> dict:
+ """Generate ejabberd mod_stun_disco service config from Coturn URI."""
+ pattern = re.compile(r'(stun|turn):(.*):([0-9]{4})\?transport=(tcp|udp)')
+ typ, domain, port, transport = pattern.match(uri).groups()
+ return {
+ "host": domain,
+ "port": int(port),
+ "type": typ,
+ "transport": transport,
+ "restricted": False
+ }
+
+
+def _generate_uris(services: list[dict]) -> list[str]:
+ """Generate STUN/TURN URIs from ejabberd mod_stun_disco service config."""
+ return [
+ f"{s['type']}:{s['host']}:{s['port']}?transport={s['transport']}"
+ for s in services
+ ]
+
+
+def subcommand_get_turn_config(_):
+ """Get the latest STUN/TURN configuration in JSON format."""
+ with open(EJABBERD_CONFIG, 'r') as file_handle:
+ conf = yaml.load(file_handle)
+
+ mod_stun_disco_config = conf['modules']['mod_stun_disco']
+ managed = os.path.exists(EJABBERD_MANAGED_COTURN)
+
+ if bool(mod_stun_disco_config):
+ print(
+ json.dumps([{
+ 'domain': '',
+ 'uris': _generate_uris(mod_stun_disco_config['services']),
+ 'shared_secret': mod_stun_disco_config['secret'],
+ }, managed]))
+ else:
+ print(
+ json.dumps([{
+ 'domain': None,
+ 'uris': [],
+ 'shared_secret': None
+ }, managed]))
+
+
+def subcommand_configure_turn(arguments):
+ """Set parameters for the STUN/TURN server to use with ejabberd."""
+ turn_server_config = json.loads(''.join(sys.stdin))
+ uris = turn_server_config['uris']
+ mod_stun_disco_config = {}
+
+ if turn_server_config['uris'] and turn_server_config['shared_secret']:
+ mod_stun_disco_config = {
+ 'credentials_lifetime': '1000d',
+ 'secret': turn_server_config['shared_secret'],
+ 'services': [_generate_service(uri) for uri in uris]
+ }
+
+ with open(EJABBERD_CONFIG, 'r') as file_handle:
+ conf = yaml.load(file_handle)
+
+ conf['modules']['mod_stun_disco'] = mod_stun_disco_config
+
+ with open(EJABBERD_CONFIG, 'w') as file_handle:
+ yaml.dump(conf, file_handle)
+
+ if arguments.managed:
+ Path(EJABBERD_MANAGED_COTURN).touch()
+ else:
+ Path(EJABBERD_MANAGED_COTURN).unlink(missing_ok=True)
+
+ if action_utils.service_is_running('ejabberd'):
+ subprocess.call(['ejabberdctl', 'reload_config'])
+
+
def _get_version():
""" Get the current ejabberd version """
try:
diff --git a/plinth/modules/coturn/components.py b/plinth/modules/coturn/components.py
index b7f91a27f..22b9b7ad3 100644
--- a/plinth/modules/coturn/components.py
+++ b/plinth/modules/coturn/components.py
@@ -2,11 +2,10 @@
"""App component for other apps to manage their STUN/TURN server configuration.
"""
-from __future__ import annotations
+from __future__ import annotations # Can be removed in Python 3.10
import json
from dataclasses import dataclass, field
-from typing import List
from plinth import app
@@ -27,15 +26,15 @@ class TurnConfiguration:
"""
domain: str = None
- uris: List[str] = field(default_factory=list)
+ uris: list[str] = field(default_factory=list)
shared_secret: str = None
def __post_init__(self):
"""Generate URIs after object initialization if necessary."""
if self.domain and not self.uris:
self.uris = [
- f'{proto1}:{self.domain}:3478?transport={proto2}'
- for proto1 in ['stun', 'turn'] for proto2 in ['tcp', 'udp']
+ f'{typ}:{self.domain}:3478?transport={transport}'
+ for typ in ['stun', 'turn'] for transport in ['tcp', 'udp']
]
def to_json(self) -> str:
@@ -73,7 +72,7 @@ class TurnConsumer(app.FollowerComponent):
self._all[component_id] = self
@classmethod
- def list(cls) -> List[TurnConsumer]: # noqa
+ def list(cls) -> list[TurnConsumer]: # noqa
"""Return a list of all Coturn components."""
return cls._all.values()
diff --git a/plinth/modules/ejabberd/__init__.py b/plinth/modules/ejabberd/__init__.py
index 99f2f7daf..7676b686f 100644
--- a/plinth/modules/ejabberd/__init__.py
+++ b/plinth/modules/ejabberd/__init__.py
@@ -9,7 +9,6 @@ import pathlib
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
-
from plinth import actions
from plinth import app as app_module
from plinth import cfg, frontpage, menu
@@ -17,6 +16,7 @@ from plinth.daemon import Daemon
from plinth.modules import config
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.modules.users.components import UsersAndGroups
@@ -26,7 +26,7 @@ from plinth.utils import format_lazy
from . import manifest
-version = 3
+version = 4
managed_services = ['ejabberd']
@@ -44,9 +44,15 @@ _description = [
'When enabled, ejabberd can be accessed by any '
' user with a {box_name} login.'),
box_name=_(cfg.box_name), users_url=reverse_lazy('users:index'),
- jsxc_url=reverse_lazy('jsxc:index'))
+ jsxc_url=reverse_lazy('jsxc:index')),
+ format_lazy(
+ _('ejabberd needs a STUN/TURN server for audio/video calls. '
+ 'Install the Coturn app or configure '
+ 'an external server.'), coturn_url=reverse_lazy('coturn:index'))
]
+depends = ['coturn']
+
logger = logging.getLogger(__name__)
app = None
@@ -113,11 +119,22 @@ class EjabberdApp(app_module.App):
**manifest.backup)
self.add(backup_restore)
+ turn = EjabberdTurnConsumer('turn-ejabberd')
+ self.add(turn)
+
pre_hostname_change.connect(on_pre_hostname_change)
post_hostname_change.connect(on_post_hostname_change)
domain_added.connect(on_domain_added)
+class EjabberdTurnConsumer(TurnConsumer):
+ """Component to manage Coturn configuration for ejabberd."""
+
+ def on_config_change(self, config):
+ """Add or update STUN/TURN configuration."""
+ update_turn_configuration(config)
+
+
def setup(helper, old_version=None):
"""Install and configure the module."""
domainname = config.get_domainname()
@@ -134,6 +151,10 @@ def setup(helper, old_version=None):
['setup', '--domainname', domainname])
helper.call('post', app.enable)
+ # Configure STUN/TURN only if there's a valid TLS domain set for Coturn
+ configuration = app.get_component('turn-ejabberd').get_configuration()
+ update_turn_configuration(configuration, force=True)
+
def get_domains():
"""Return the list of domains that ejabberd is interested in.
@@ -194,3 +215,23 @@ def on_domain_added(sender, domain_type, name, description='', services=None,
if name not in conf['domains']:
actions.superuser_run('ejabberd', ['add-domain', '--domainname', name])
app.get_component('letsencrypt-ejabberd').setup_certificates()
+
+
+def update_turn_configuration(config: TurnConfiguration, managed=True,
+ force=False):
+ """Update ejabberd's 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('ejabberd', params, input=config.to_json().encode())
+
+
+def get_turn_configuration() -> (TurnConfiguration, bool):
+ """Get the latest STUN/TURN configuration."""
+ json_config = actions.superuser_run('ejabberd', ['get-turn-config'])
+ tc, managed = json.loads(json_config)
+ return (TurnConfiguration(tc['domain'], tc['uris'],
+ tc['shared_secret']), managed)
diff --git a/plinth/modules/ejabberd/forms.py b/plinth/modules/ejabberd/forms.py
index d5faea6e1..ff0825ade 100644
--- a/plinth/modules/ejabberd/forms.py
+++ b/plinth/modules/ejabberd/forms.py
@@ -4,6 +4,7 @@ Forms for configuring Ejabberd.
"""
from django import forms
+from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from plinth import cfg
@@ -20,3 +21,28 @@ class EjabberdForm(forms.Form):
'clients, and reading the history of a multi-user chat room. '
'It depends on the client settings whether the histories are '
'stored as plain text or encrypted.'), box_name=_(cfg.box_name)))
+
+ 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 ejabberd. 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/ejabberd/static/ejabberd.js b/plinth/modules/ejabberd/static/ejabberd.js
new file mode 100644
index 000000000..6822a474e
--- /dev/null
+++ b/plinth/modules/ejabberd/static/ejabberd.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/ejabberd/templates/ejabberd.html b/plinth/modules/ejabberd/templates/ejabberd.html
index fcf3fba51..c220994fd 100644
--- a/plinth/modules/ejabberd/templates/ejabberd.html
+++ b/plinth/modules/ejabberd/templates/ejabberd.html
@@ -3,8 +3,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
-{% load i18n %}
{% load bootstrap %}
+{% load i18n %}
+{% load static %}
{% block description %}
@@ -28,3 +29,8 @@
{% endblock %}
+
+{% block page_js %}
+
+{% endblock %}
diff --git a/plinth/modules/ejabberd/tests/conftest.py b/plinth/modules/ejabberd/tests/conftest.py
new file mode 100644
index 000000000..599814073
--- /dev/null
+++ b/plinth/modules/ejabberd/tests/conftest.py
@@ -0,0 +1,78 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Common test fixtures for ejabberd.
+"""
+
+import importlib
+import pathlib
+import shutil
+import types
+from unittest.mock import MagicMock, patch
+
+import pytest
+from plinth.modules import ejabberd
+
+current_directory = pathlib.Path(__file__).parent
+
+
+def _load_actions_module():
+ actions_file_path = str(current_directory / '..' / '..' / '..' / '..' /
+ 'actions' / 'ejabberd')
+ loader = importlib.machinery.SourceFileLoader('ejabberd',
+ actions_file_path)
+ module = types.ModuleType(loader.name)
+ loader.exec_module(module)
+ return module
+
+
+actions = _load_actions_module()
+
+
+@pytest.fixture(name='conf_file')
+def fixture_conf_file(tmp_path):
+ """Uses a dummy configuration file."""
+ settings_file_name = 'ejabberd.yml'
+ conf_file = tmp_path / settings_file_name
+ conf_file.touch()
+ shutil.copyfile(str(current_directory / 'data' / 'ejabberd.yml.example'),
+ str(conf_file))
+ return str(conf_file)
+
+
+@pytest.fixture(name='managed_file')
+def fixture_managed_file(tmp_path):
+ """Uses a dummy managed file."""
+ file_name = 'freedombox_managed_coturn'
+ fil = tmp_path / file_name
+ return str(fil)
+
+
+@pytest.fixture(name='call_action')
+def fixture_call_action(capsys, conf_file, managed_file):
+ """Run actions with custom root path."""
+
+ def _call_action(module_name, args, **kwargs):
+ actions.EJABBERD_CONFIG = conf_file
+ actions.EJABBERD_MANAGED_COTURN = managed_file
+ with patch('argparse._sys.argv', [module_name] + args):
+ actions.main()
+ captured = capsys.readouterr()
+ return captured.out
+
+ return _call_action
+
+
+@pytest.fixture(name='test_configuration', autouse=True)
+def fixture_test_configuration(call_action, conf_file):
+ """Use a separate ejabberd configuration for tests.
+
+ Patches actions.superuser_run with the fixture call_action.
+ The module state is patched to be 'up-to-date'.
+ """
+ with patch('plinth.actions.superuser_run', call_action):
+
+ helper = MagicMock()
+ helper.get_state.return_value = 'up-to-date'
+ ejabberd.setup_helper = helper
+
+ yield
diff --git a/plinth/modules/ejabberd/tests/data/ejabberd.yml.example b/plinth/modules/ejabberd/tests/data/ejabberd.yml.example
new file mode 100644
index 000000000..c6f36dbd4
--- /dev/null
+++ b/plinth/modules/ejabberd/tests/data/ejabberd.yml.example
@@ -0,0 +1,231 @@
+###
+### ejabberd configuration file
+###
+### The parameters used in this configuration file are explained at
+###
+### https://docs.ejabberd.im/admin/configuration
+###
+### The configuration file is written in YAML.
+### *******************************************************
+### ******* !!! WARNING !!! *******
+### ******* YAML IS INDENTATION SENSITIVE *******
+### ******* MAKE SURE YOU INDENT SECTIONS CORRECTLY *******
+### *******************************************************
+### Refer to http://en.wikipedia.org/wiki/YAML for the brief description.
+###
+
+hosts:
+ - localhost
+
+loglevel: info
+
+## If you already have certificates, list them here
+# certfiles:
+# - /etc/letsencrypt/live/domain.tld/fullchain.pem
+# - /etc/letsencrypt/live/domain.tld/privkey.pem
+
+listen:
+ -
+ port: 5222
+ ip: "::"
+ module: ejabberd_c2s
+ max_stanza_size: 262144
+ shaper: c2s_shaper
+ access: c2s
+ starttls_required: true
+ -
+ port: 5269
+ ip: "::"
+ module: ejabberd_s2s_in
+ max_stanza_size: 524288
+ -
+ port: 5443
+ ip: "::"
+ module: ejabberd_http
+ tls: true
+ request_handlers:
+ /admin: ejabberd_web_admin
+ /api: mod_http_api
+ /bosh: mod_bosh
+ /captcha: ejabberd_captcha
+ /upload: mod_http_upload
+ /ws: ejabberd_http_ws
+ -
+ port: 5280
+ ip: "::"
+ module: ejabberd_http
+ request_handlers:
+ /admin: ejabberd_web_admin
+ /.well-known/acme-challenge: ejabberd_acme
+ -
+ port: 3478
+ ip: "::"
+ transport: udp
+ module: ejabberd_stun
+ use_turn: true
+ ## The server's public IPv4 address:
+ # turn_ipv4_address: "203.0.113.3"
+ ## The server's public IPv6 address:
+ # turn_ipv6_address: "2001:db8::3"
+ -
+ port: 1883
+ ip: "::"
+ module: mod_mqtt
+ backlog: 1000
+
+s2s_use_starttls: optional
+
+acl:
+ local:
+ user_regexp: ""
+ loopback:
+ ip:
+ - 127.0.0.0/8
+ - ::1/128
+
+access_rules:
+ local:
+ allow: local
+ c2s:
+ deny: blocked
+ allow: all
+ announce:
+ allow: admin
+ configure:
+ allow: admin
+ muc_create:
+ allow: local
+ pubsub_createnode:
+ allow: local
+ trusted_network:
+ allow: loopback
+
+api_permissions:
+ "console commands":
+ from:
+ - ejabberd_ctl
+ who: all
+ what: "*"
+ "admin access":
+ who:
+ access:
+ allow:
+ - acl: loopback
+ - acl: admin
+ oauth:
+ scope: "ejabberd:admin"
+ access:
+ allow:
+ - acl: loopback
+ - acl: admin
+ what:
+ - "*"
+ - "!stop"
+ - "!start"
+ "public commands":
+ who:
+ ip: 127.0.0.1/8
+ what:
+ - status
+ - connected_users_number
+
+shaper:
+ normal:
+ rate: 3000
+ burst_size: 20000
+ fast: 100000
+
+shaper_rules:
+ max_user_sessions: 10
+ max_user_offline_messages:
+ 5000: admin
+ 100: all
+ c2s_shaper:
+ none: admin
+ normal: all
+ s2s_shaper: fast
+
+modules:
+ mod_adhoc: {}
+ mod_admin_extra: {}
+ mod_announce:
+ access: announce
+ mod_avatar: {}
+ mod_blocking: {}
+ mod_bosh: {}
+ mod_caps: {}
+ mod_carboncopy: {}
+ mod_client_state: {}
+ mod_configure: {}
+ mod_disco: {}
+ mod_fail2ban: {}
+ mod_http_api: {}
+ mod_http_upload:
+ put_url: https://@HOST@:5443/upload
+ custom_headers:
+ "Access-Control-Allow-Origin": "https://@HOST@"
+ "Access-Control-Allow-Methods": "GET,HEAD,PUT,OPTIONS"
+ "Access-Control-Allow-Headers": "Content-Type"
+ mod_last: {}
+ mod_mam:
+ ## Mnesia is limited to 2GB, better to use an SQL backend
+ ## For small servers SQLite is a good fit and is very easy
+ ## to configure. Uncomment this when you have SQL configured:
+ ## db_type: sql
+ assume_mam_usage: true
+ default: always
+ mod_mqtt: {}
+ mod_muc:
+ access:
+ - allow
+ access_admin:
+ - allow: admin
+ access_create: muc_create
+ access_persistent: muc_create
+ access_mam:
+ - allow
+ default_room_options:
+ mam: true
+ mod_muc_admin: {}
+ mod_offline:
+ access_max_user_messages: max_user_offline_messages
+ mod_ping: {}
+ mod_privacy: {}
+ mod_private: {}
+ mod_proxy65:
+ access: local
+ max_connections: 5
+ mod_pubsub:
+ access_createnode: pubsub_createnode
+ plugins:
+ - flat
+ - pep
+ force_node_config:
+ ## Avoid buggy clients to make their bookmarks public
+ storage:bookmarks:
+ access_model: whitelist
+ mod_push: {}
+ mod_push_keepalive: {}
+ mod_register:
+ ## Only accept registration requests from the "trusted"
+ ## network (see access_rules section above).
+ ## Think twice before enabling registration from any
+ ## address. See the Jabber SPAM Manifesto for details:
+ ## https://github.com/ge0rg/jabber-spam-fighting-manifesto
+ ip_access: trusted_network
+ mod_roster:
+ versioning: true
+ mod_s2s_dialback: {}
+ mod_shared_roster: {}
+ mod_stream_mgmt:
+ resend_on_timeout: if_offline
+ mod_stun_disco: {}
+ mod_vcard: {}
+ mod_vcard_xupdate: {}
+ mod_version:
+ show_os: false
+
+### Local Variables:
+### mode: yaml
+### End:
+### vim: set filetype=yaml tabstop=8
diff --git a/plinth/modules/ejabberd/tests/test_turn_config.py b/plinth/modules/ejabberd/tests/test_turn_config.py
new file mode 100644
index 000000000..a27171388
--- /dev/null
+++ b/plinth/modules/ejabberd/tests/test_turn_config.py
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Test module for ejabberd STUN/TURN configuration.
+"""
+
+from unittest.mock import patch
+
+from plinth.modules import ejabberd
+from plinth.modules.coturn.components import TurnConfiguration
+
+managed_configuration = TurnConfiguration(
+ 'freedombox.local', [],
+ 'aiP02OAGyOlj6WGuCyqj7iaOsbkC7BUeKvKzhAsTZ8MEwMd3yTwpr2uvbOxgWe51')
+
+overridden_configuration = TurnConfiguration(
+ 'public.coturn.site', [],
+ 'BUeKvKzhAsTZ8MEwMd3yTwpr2uvbOxgWe51aiP02OAGyOlj6WGuCyqj7iaOsbkC7')
+
+
+def _set_turn_configuration(monkeypatch, config=managed_configuration,
+ managed=True):
+ monkeypatch.setattr('sys.stdin', config.to_json())
+ with patch('plinth.action_utils.service_is_running', return_value=False):
+ ejabberd.update_turn_configuration(config, managed=managed)
+
+
+def _assert_conf(expected_configuration, expected_managed):
+ """Assert that ejabberd TURN configuration is as expected."""
+ config, managed = ejabberd.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):
+ _set_turn_configuration(monkeypatch)
+ _assert_conf(managed_configuration, True)
+
+
+def test_overridden_turn_server_configuration(monkeypatch):
+ _set_turn_configuration(monkeypatch, overridden_configuration, False)
+ _assert_conf(overridden_configuration, False)
+
+
+def test_remove_turn_configuration(monkeypatch):
+ _set_turn_configuration(monkeypatch)
+ _set_turn_configuration(monkeypatch, TurnConfiguration(), False)
+ _assert_conf(TurnConfiguration(), False)
diff --git a/plinth/modules/ejabberd/views.py b/plinth/modules/ejabberd/views.py
index d96dbf271..ad6f99bcd 100644
--- a/plinth/modules/ejabberd/views.py
+++ b/plinth/modules/ejabberd/views.py
@@ -3,11 +3,12 @@
Views for the Ejabberd module
"""
+from plinth.modules.coturn.components import TurnConfiguration
from django.contrib import messages
from django.utils.translation import ugettext as _
from plinth import actions
-from plinth.modules import ejabberd
+from plinth.modules import coturn, ejabberd
from plinth.views import AppView
from .forms import EjabberdForm
@@ -20,9 +21,14 @@ class EjabberdAppView(AppView):
form_class = EjabberdForm
def get_initial(self):
- initdict = super().get_initial()
- initdict.update({'MAM_enabled': self.is_MAM_enabled()})
- return initdict
+ """Initial data to fill in the form."""
+ config, managed = ejabberd.get_turn_configuration()
+ return super().get_initial() | {
+ 'MAM_enabled': self.is_MAM_enabled(),
+ 'enable_managed_turn': managed,
+ 'turn_uris': '\n'.join(config.uris),
+ 'shared_secret': config.shared_secret
+ }
def get_context_data(self, *args, **kwargs):
"""Add service to the context data."""
@@ -31,21 +37,52 @@ class EjabberdAppView(AppView):
context['domainname'] = domains[0] if domains else None
return context
+ @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:
+ ejabberd.update_turn_configuration(
+ TurnConfiguration(None, new_turn_uris, new_shared_secret),
+ managed=False)
+ else:
+ ejabberd.update_turn_configuration(coturn.get_config(),
+ managed=True)
+
+ @staticmethod
+ def _handle_MAM_configuration(old_config, new_config):
+ # note ejabberd action "enable" or "disable" restarts, if running
+ if new_config['MAM_enabled']:
+ actions.superuser_run('ejabberd', ['mam', 'enable'])
+ else:
+ actions.superuser_run('ejabberd', ['mam', 'disable'])
+
def form_valid(self, form):
"""Enable/disable a service and set messages."""
- old_status = form.initial
- new_status = form.cleaned_data
+ old_config = form.initial
+ new_config = form.cleaned_data
- if old_status['MAM_enabled'] != new_status['MAM_enabled']:
- # note ejabberd action "enable" or "disable" restarts, if running
- if new_status['MAM_enabled']:
- actions.superuser_run('ejabberd', ['mam', 'enable'])
- messages.success(self.request,
- _('Message Archive Management enabled'))
- else:
- actions.superuser_run('ejabberd', ['mam', 'disable'])
- messages.success(self.request,
- _('Message Archive Management disabled'))
+ def changed(prop):
+ return old_config[prop] != new_config[prop]
+
+ is_changed = False
+ if changed('MAM_enabled'):
+ self._handle_MAM_configuration(old_config, 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)