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