From b4e6c03bd78c73ca946e706b58089a24077b706c Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 23 Apr 2020 15:41:15 -0700 Subject: [PATCH] coturn: New app to manage Coturn TURN/STUN server - Shows URLs and shared secret that communication servers like matrix-synapse should be configured to. Later we will implement auto-configuring those servers. - Allow selecting domain for the sake of TLS/DTLS certificate installation. - Simplify systemd service file options. Drop log file and pid file support as they are not needed with systemd. Add security options. - Set custom configuration file by overriding systemd service file options so that we don't have a problem with conffile prompts. - Implement functional tests (and automatic diagnostics). - Custom icon selected from the Noun project as Coturn project does not have one. - Backup/restore configuration file and certificates. - Document some questions regarding configuration options. Tests performed: - App is not listed in the app page if 'advanced' flag is disabled. - App name, icon and short description shows up correctly in apps page. - App name, icon, short description, description, manual link, enable/disable button and diagnostics link show up currently in app page. - Verify that configuration used by coturn server is the FreedomBox configuration by checking the cert path in the log output. - PID file is not created in /var/run/turnserver/. It goes into /dev/null according to the log output. - No log file is created other than what is collected by systemd from command line. - systemctl show coturn.service shows all the intended restrictions such as NoNewPrivileges, Protect* options. - Run functional tests. - Ensure that backup of configuration file works by taking backup, changing the secret and restoring. During backup and restore coturn should be stopped and started as per logs. - Build Debian package. No warnings about the copyright file. - Enabling the app enables the service and runs it. - Disabling the app disables the service and stop it. - All diagnostics tests pass. - Diagnostic tests show firewall port coturn-freedombox for internal and external networks, service coturn, and each listening port for udp4, udp6, tcp4 and tcp6. - Information in the firewall page shows up properly. Enabling the app opens firewall ports, and disabling it closes them. - When the app is installed, if a cert domain is available, it will be used. When multiple domains are available, one of them is picked. - Status shows 4 URLs with the currently selected domain and secret key. - Changing domain to another domain succeeds and reflects in the status information. - When no domain is configured. Installing the app succeeds. No domain is shown in the list of domains. - When domain is changed, the certificates files in /etc/coturn/certs are overwritten. - Certificates have the ownership turnserver:turnserver. Public key is cert.pem has 644 permissions. Private is pkey.pem has 600 permissions. /etc/coturn/certs is owned by root:root. - Let's encrypt certificates are setup immediately after install. - Port forwarding information shows all ports except for relay ports. - Trying to create a user with username 'turnserver' throws an error. This happens even when coturn is not installed yet. - After installing coturn, the configuration file /etc/coturn/freedombox.conf is created with ownership root:turnserver and permissions 640. The directory /etc/coturn is created with ownership root:root and permissions 755. Signed-off-by: Sunil Mohan Adapa [jvalleroy: Fix copied form_valid comment] Signed-off-by: James Valleroy Reviewed-by: James Valleroy --- actions/coturn | 116 +++++++++++++ debian/copyright | 6 + functional_tests/features/coturn.feature | 27 +++ plinth/modules/coturn/__init__.py | 156 ++++++++++++++++++ .../data/etc/plinth/modules-enabled/coturn | 1 + .../system/coturn.service.d/freedombox.conf | 20 +++ .../firewalld/services/coturn-freedombox.xml | 15 ++ plinth/modules/coturn/forms.py | 27 +++ plinth/modules/coturn/manifest.py | 10 ++ plinth/modules/coturn/templates/coturn.html | 32 ++++ plinth/modules/coturn/tests/__init__.py | 0 plinth/modules/coturn/urls.py | 12 ++ plinth/modules/coturn/views.py | 42 +++++ static/themes/default/icons/coturn.png | Bin 0 -> 4708 bytes static/themes/default/icons/coturn.svg | 67 ++++++++ 15 files changed, 531 insertions(+) create mode 100755 actions/coturn create mode 100644 functional_tests/features/coturn.feature create mode 100644 plinth/modules/coturn/__init__.py create mode 100644 plinth/modules/coturn/data/etc/plinth/modules-enabled/coturn create mode 100644 plinth/modules/coturn/data/lib/systemd/system/coturn.service.d/freedombox.conf create mode 100644 plinth/modules/coturn/data/usr/lib/firewalld/services/coturn-freedombox.xml create mode 100644 plinth/modules/coturn/forms.py create mode 100644 plinth/modules/coturn/manifest.py create mode 100644 plinth/modules/coturn/templates/coturn.html create mode 100644 plinth/modules/coturn/tests/__init__.py create mode 100644 plinth/modules/coturn/urls.py create mode 100644 plinth/modules/coturn/views.py create mode 100644 static/themes/default/icons/coturn.png create mode 100644 static/themes/default/icons/coturn.svg 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 0000000000000000000000000000000000000000..848ccdfe7673bea3679c8b42c32fd99a68d4680a GIT binary patch literal 4708 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 + + + + + image/svg+xml + + Communication, video, call, camera, talk, record, media + + + + + + Communication, video, call, camera, talk, record, media + + + +