diff --git a/actions/coturn b/actions/coturn new file mode 100755 index 000000000..e50f2af14 --- /dev/null +++ b/actions/coturn @@ -0,0 +1,116 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Configuration helper for Coturn daemon. +""" + +import argparse +import json +import pathlib +import random +import shutil +import string + +import augeas + +from plinth import action_utils + +CONFIG_FILE = pathlib.Path('/etc/coturn/freedombox.conf') + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser('setup', help='Setup Coturn server') + subparsers.add_parser('get-config', + help='Return the current configuration') + subparser = subparsers.add_parser('set-domain', help='Set the TLS domain') + subparser.add_argument('domain_name', help='TLS domain name to set') + + subparsers.required = True + return parser.parse_args() + + +def _key_path(key): + """Return the augeas path for a key.""" + return '/files' + str(CONFIG_FILE) + '/' + key + + +def subcommand_setup(_): + """Setup Coturn server.""" + CONFIG_FILE.parent.mkdir(exist_ok=True) + if not CONFIG_FILE.exists(): + CONFIG_FILE.touch(0o640) + shutil.chown(CONFIG_FILE, group='turnserver') + + action_utils.service_daemon_reload() + action_utils.service_try_restart('coturn') + + aug = augeas_load() + + # XXX: Should we set listen, relay IP address to :: or dynamically + # XXX: Should we set external-ip + aug.set(_key_path('min-port'), '49152') + aug.set(_key_path('max-port'), '50175') + aug.set(_key_path('use-auth-secret'), 'true') + if not aug.get(_key_path('static-auth-secret')): + secret = ''.join( + random.choice(string.ascii_letters + string.digits) + for _ in range(64)) + aug.set(_key_path('static-auth-secret'), secret) + + aug.set(_key_path('cert'), '/etc/coturn/certs/cert.pem') + aug.set(_key_path('pkey'), '/etc/coturn/certs/pkey.pem') + aug.set(_key_path('no-tlsv1'), 'true') + aug.set(_key_path('no-tlsv1_1'), 'true') + aug.set(_key_path('no-cli'), 'true') + + aug.save() + + +def subcommand_get_config(_): + """Return the current configuration in JSON format.""" + aug = augeas_load() + config = { + 'static_auth_secret': aug.get(_key_path('static-auth-secret')), + 'realm': aug.get(_key_path('realm')), + } + print(json.dumps(config)) + + +def subcommand_set_domain(arguments): + """Set the TLS domain. + + This value is usually not stored. So, set realm value even though it is not + needed to set realm for REST API based authentication. + + """ + aug = augeas_load() + aug.set(_key_path('realm'), arguments.domain_name) + aug.save() + + +def augeas_load(): + """Initialize Augeas.""" + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.set('/augeas/load/Simplevars/lens', 'Simplevars.lns') + aug.set('/augeas/load/Simplevars/incl[last() + 1]', str(CONFIG_FILE)) + aug.load() + + return aug + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/debian/copyright b/debian/copyright index 018d9095f..30063f136 100644 --- a/debian/copyright +++ b/debian/copyright @@ -41,6 +41,12 @@ Copyright: Cockpit Authors (https://github.com/cockpit-project/cockpit/blob/mast Comment: https://github.com/cockpit-project/cockpit/blob/master/src/branding/default/logo.svg License: LGPL-2.1+ +Files: static/themes/default/icons/coturn.png + static/themes/default/icons/coturn.svg +Copyright: Kmg Design (https://thenounproject.com/kmgdesignid/) +Comment: Video Call by Kmg Design from the Noun Project https://thenounproject.com/term/video-call/3256092/ +License: CC-BY-3.0-US + Files: static/themes/default/icons/deluge.png Copyright: 2007 Andrew Wedderburn Comment: https://upload.wikimedia.org/wikipedia/commons/thumb/8/85//Deluge-Logo.svg/2000px-Deluge-Logo.svg.png diff --git a/functional_tests/features/coturn.feature b/functional_tests/features/coturn.feature new file mode 100644 index 000000000..b707c02fe --- /dev/null +++ b/functional_tests/features/coturn.feature @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +@apps @coturn @backups +Feature: Coturn STUN/TURN Server + Run the Coturn STUN/TURN server. + +Background: + Given I'm a logged in user + And advanced mode is on + And the coturn application is installed + +Scenario: Enable coturn application + Given the coturn application is disabled + When I enable the coturn application + Then the coturn service should be running + +# TODO: Improve this by checking that secret and domain did not change +Scenario: Backup and restore coturn + Given the coturn application is enabled + When I create a backup of the coturn app data + And I restore the coturn app data backup + Then the coturn service should be running + +Scenario: Disable coturn application + Given the coturn application is enabled + When I disable the coturn application + Then the coturn service should not be running diff --git a/plinth/modules/coturn/__init__.py b/plinth/modules/coturn/__init__.py new file mode 100644 index 000000000..2f80bea4a --- /dev/null +++ b/plinth/modules/coturn/__init__.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app to configure Coturn server. +""" + +import json +import pathlib + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import app as app_module +from plinth import menu +from plinth.daemon import Daemon +from plinth.modules import names +from plinth.modules.firewall.components import Firewall +from plinth.modules.letsencrypt.components import LetsEncrypt +from plinth.modules.users.components import UsersAndGroups + +from .manifest import backup # noqa, pylint: disable=unused-import + +version = 1 + +managed_services = ['coturn'] + +managed_packages = ['coturn'] + +managed_paths = [pathlib.Path('/etc/coturn/')] + +_description = [ + _('Coturn is a server to facilitate audio/video calls and conferences by ' + 'providing an implementation of TURN and STUN protocols. WebRTC, SIP ' + 'and other communication servers can use it to establish a call between ' + 'parties who are otherwise unable connect to each other.'), + _('It is not meant to be used directly by users. Servers such as ' + 'matrix-synapse need to be configured with the details provided here.'), +] + +port_forwarding_info = [ + ('UDP', 3478), + ('TCP', 3478), + ('UDP', 3479), + ('TCP', 3479), + ('UDP', 5349), + ('TCP', 5349), + ('UDP', 5350), + ('TCP', 5350), + # XXX: Add relay ports here +] + +app = None + + +class CoturnApp(app_module.App): + """FreedomBox app for Coturn.""" + + app_id = 'coturn' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + info = app_module.Info(app_id=self.app_id, version=version, + name=_('Coturn'), icon_filename='coturn', + short_description=_('VoIP Helper'), + description=_description, manual_page='Coturn') + self.add(info) + + menu_item = menu.Menu('menu-coturn', info.name, info.short_description, + info.icon_filename, 'coturn:index', + parent_url_name='apps', advanced=True) + self.add(menu_item) + + firewall = Firewall('firewall-coturn', info.name, + ports=['coturn-freedombox'], is_external=True) + self.add(firewall) + + letsencrypt = LetsEncrypt( + 'letsencrypt-coturn', domains=get_domains, + daemons=managed_services, should_copy_certificates=True, + private_key_path='/etc/coturn/certs/pkey.pem', + certificate_path='/etc/coturn/certs/cert.pem', + user_owner='turnserver', group_owner='turnserver', + managing_app='coturn') + self.add(letsencrypt) + + daemon = Daemon( + 'daemon-coturn', managed_services[0], + listen_ports=[(3478, 'udp4'), (3478, 'udp6'), (3478, 'tcp4'), + (3478, 'tcp6'), (3479, 'udp4'), (3479, 'udp6'), + (3479, 'tcp4'), (3479, 'tcp6'), (5349, 'udp4'), + (5349, 'udp6'), (5349, 'tcp4'), (5349, 'tcp6'), + (5350, 'udp4'), (5350, 'udp6'), (5350, 'tcp4'), + (5350, 'tcp6')]) + self.add(daemon) + + users_and_groups = UsersAndGroups('users-and-groups-coturn', + reserved_usernames=['turnserver']) + self.add(users_and_groups) + + +def init(): + """Initialize the Coturn module.""" + global app + app = CoturnApp() + + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): + app.set_enabled(True) + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + helper.call('post', actions.superuser_run, 'coturn', ['setup']) + helper.call('post', app.enable) + app.get_component('letsencrypt-coturn').setup_certificates() + + +def get_available_domains(): + """Return an iterator with all domains able to have a certificate.""" + return (domain.name for domain in names.components.DomainName.list() + if domain.domain_type.can_have_certificate) + + +def get_domain(): + """Read TLS domain from config file select first available if none.""" + config = get_config() + if config['realm']: + return get_config()['realm'] + + domain = next(get_available_domains(), None) + set_domain(domain) + + return domain + + +def get_domains(): + """Return a list with the configured domains.""" + domain = get_domain() + if domain: + return [domain] + + return [] + + +def set_domain(domain): + """Set the TLS domain by writing a file to data directory.""" + if domain: + actions.superuser_run('coturn', ['set-domain', domain]) + + +def get_config(): + """Return the coturn server configuration.""" + output = actions.superuser_run('coturn', ['get-config']) + return json.loads(output) diff --git a/plinth/modules/coturn/data/etc/plinth/modules-enabled/coturn b/plinth/modules/coturn/data/etc/plinth/modules-enabled/coturn new file mode 100644 index 000000000..c31be5ce2 --- /dev/null +++ b/plinth/modules/coturn/data/etc/plinth/modules-enabled/coturn @@ -0,0 +1 @@ +plinth.modules.coturn diff --git a/plinth/modules/coturn/data/lib/systemd/system/coturn.service.d/freedombox.conf b/plinth/modules/coturn/data/lib/systemd/system/coturn.service.d/freedombox.conf new file mode 100644 index 000000000..a59637990 --- /dev/null +++ b/plinth/modules/coturn/data/lib/systemd/system/coturn.service.d/freedombox.conf @@ -0,0 +1,20 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/turnserver -c /etc/coturn/freedombox.conf --pidfile=/dev/null --log-file=- +ExecStartPost= +LockPersonality=yes +NoNewPrivileges=yes +PIDFile= +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictRealtime=yes +SystemCallArchitectures=native +Type=simple diff --git a/plinth/modules/coturn/data/usr/lib/firewalld/services/coturn-freedombox.xml b/plinth/modules/coturn/data/usr/lib/firewalld/services/coturn-freedombox.xml new file mode 100644 index 000000000..33832d60b --- /dev/null +++ b/plinth/modules/coturn/data/usr/lib/firewalld/services/coturn-freedombox.xml @@ -0,0 +1,15 @@ + + + Coturn STUN/TURN server + Coturn is a server to facilitate audio/video calls and conferences by providing implementation of TURN and STUN protocols. WebRTC, SIP and other communication servers can use it to establish a call between parties who are otherwise unable connect with each other. Enable this if you are running the communications server and wish to perform audio/video calls. + + + + + + + + + + + diff --git a/plinth/modules/coturn/forms.py b/plinth/modules/coturn/forms.py new file mode 100644 index 000000000..53f89d199 --- /dev/null +++ b/plinth/modules/coturn/forms.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Forms for Coturn app. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules import coturn + + +def get_domain_choices(): + """Double domain entries for inclusion in the choice field.""" + return ((domain, domain) for domain in coturn.get_available_domains()) + + +class CoturnForm(forms.Form): + """Form to select a TLS domain for Coturn.""" + + domain = forms.ChoiceField( + choices=get_domain_choices, + label=_('TLS domain'), + help_text=_( + 'Select a domain to use TLS with. If the list is empty, please ' + 'configure at least one domain with certificates.'), + required=False, + ) diff --git a/plinth/modules/coturn/manifest.py b/plinth/modules/coturn/manifest.py new file mode 100644 index 000000000..fc0748a4b --- /dev/null +++ b/plinth/modules/coturn/manifest.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from plinth.modules.backups.api import validate as validate_backup + +backup = validate_backup({ + 'secrets': { + 'directories': ['/etc/coturn'] + }, + 'services': ['coturn'] +}) diff --git a/plinth/modules/coturn/templates/coturn.html b/plinth/modules/coturn/templates/coturn.html new file mode 100644 index 000000000..f90905bd8 --- /dev/null +++ b/plinth/modules/coturn/templates/coturn.html @@ -0,0 +1,32 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block status %} + {{ block.super }} + +

Status

+ +

+ {% blocktrans trimmed %} + Use the following URLs to configure your communication server: + {% endblocktrans %} + +

stun:{{ config.realm }}:3478?transport=udp
+stun:{{ config.realm }}:3478?transport=tcp
+turn:{{ config.realm }}:3478?transport=udp
+turn:{{ config.realm }}:3478?transport=tcp
+

+ +

+ {% blocktrans trimmed %} + Use the following shared authentication secret: + {% endblocktrans %} + +

{{ config.static_auth_secret }}
+

+{% endblock %} diff --git a/plinth/modules/coturn/tests/__init__.py b/plinth/modules/coturn/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/coturn/urls.py b/plinth/modules/coturn/urls.py new file mode 100644 index 000000000..7dd786ea6 --- /dev/null +++ b/plinth/modules/coturn/urls.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the Coturn module. +""" + +from django.conf.urls import url + +from .views import CoturnAppView + +urlpatterns = [ + url(r'^apps/coturn/$', CoturnAppView.as_view(), name='index'), +] diff --git a/plinth/modules/coturn/views.py b/plinth/modules/coturn/views.py new file mode 100644 index 000000000..3d2b6c52d --- /dev/null +++ b/plinth/modules/coturn/views.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Views for Coturn app. +""" + +from django.contrib import messages +from django.utils.translation import ugettext_lazy as _ + +import plinth.modules.coturn as coturn +from plinth import views + +from . import forms + + +class CoturnAppView(views.AppView): + """Serve configuration page.""" + app_id = 'coturn' + template_name = 'coturn.html' + form_class = forms.CoturnForm + port_forwarding_info = coturn.port_forwarding_info + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['config'] = coturn.get_config() + return context + + def get_initial(self): + """Return the values to fill in the form.""" + initial = super().get_initial() + initial['domain'] = coturn.get_domain() + return initial + + def form_valid(self, form): + """Change the domain of Coturn service.""" + data = form.cleaned_data + if coturn.get_domain() != data['domain']: + coturn.set_domain(data['domain']) + coturn.app.get_component('letsencrypt-coturn').setup_certificates() + messages.success(self.request, _('Configuration updated')) + + return super().form_valid(form) diff --git a/static/themes/default/icons/coturn.png b/static/themes/default/icons/coturn.png new file mode 100644 index 000000000..848ccdfe7 Binary files /dev/null and b/static/themes/default/icons/coturn.png differ diff --git a/static/themes/default/icons/coturn.svg b/static/themes/default/icons/coturn.svg new file mode 100644 index 000000000..66cf8022c --- /dev/null +++ b/static/themes/default/icons/coturn.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + Communication, video, call, camera, talk, record, media + + + + + + Communication, video, call, camera, talk, record, media + + + +