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 @@
+
+
+ {% 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 @@
+
+