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 <sunil@medhas.org>
[jvalleroy: Fix copied form_valid comment]
Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2020-04-23 15:41:15 -07:00 committed by James Valleroy
parent efe84419b1
commit b4e6c03bd7
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
15 changed files with 531 additions and 0 deletions

116
actions/coturn Executable file
View File

@ -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()

6
debian/copyright vendored
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@
plinth.modules.coturn

View File

@ -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

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>Coturn STUN/TURN server</short>
<description>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.</description>
<port protocol="tcp" port="3478"/>
<port protocol="udp" port="3478"/>
<port protocol="tcp" port="3479"/>
<port protocol="udp" port="3479"/>
<port protocol="tcp" port="5349"/>
<port protocol="udp" port="5349"/>
<port protocol="tcp" port="5350"/>
<port protocol="udp" port="5350"/>
<port protocol="tcp" port="49152-50175"/>
<port protocol="udp" port="49152-50175"/>
</service>

View File

@ -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,
)

View File

@ -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']
})

View File

@ -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 }}
<h3>Status</h3>
<p>
{% blocktrans trimmed %}
Use the following URLs to configure your communication server:
{% endblocktrans %}
<pre>stun:{{ config.realm }}:3478?transport=udp
stun:{{ config.realm }}:3478?transport=tcp
turn:{{ config.realm }}:3478?transport=udp
turn:{{ config.realm }}:3478?transport=tcp</pre>
</p>
<p>
{% blocktrans trimmed %}
Use the following shared authentication secret:
{% endblocktrans %}
<pre>{{ config.static_auth_secret }}</pre>
</p>
{% endblock %}

View File

View File

@ -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'),
]

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 512 512"
x="0px"
y="0px"
version="1.1"
id="svg12"
sodipodi:docname="coturn.svg"
width="512"
height="512"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
inkscape:export-filename="/home/bunny/work/freedombox/plinth/static/themes/default/icons/coturn.png"
inkscape:export-xdpi="48"
inkscape:export-ydpi="48">
<metadata
id="metadata18">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>Communication, video, call, camera, talk, record, media</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs16" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1782"
inkscape:window-height="1122"
id="namedview14"
showgrid="false"
inkscape:zoom="0.73750001"
inkscape:cx="23.98607"
inkscape:cy="48.884982"
inkscape:window-x="1233"
inkscape:window-y="454"
inkscape:window-maximized="0"
inkscape:current-layer="svg12" />
<title
id="title2">Communication, video, call, camera, talk, record, media</title>
<g
data-name="Video Call"
id="g6"
transform="matrix(10.24,0,0,10.24,-71.68,-71.68)">
<path
d="m 36.1963,35.02 -5,-1 a 0.9967,0.9967 0 0 0 -1.1025,0.5576 l -2.1484,4.6061 a 19.09,19.09 0 0 1 -9.1286,-9.1286 l 4.6061,-2.1483 a 1.0008,1.0008 0 0 0 0.5576,-1.1026 l -1,-5 A 1,1 0 0 0 22,21 h -6 a 1,1 0 0 0 -1,1 21.0508,21.0508 0 0 0 21,21 1,1 0 0 0 1,-1 V 36 A 1,1 0 0 0 36.1963,35.02 Z M 21.8652,26.4258 18.06,28.2009 A 16.9646,16.9646 0 0 1 17.0507,23 h 4.13 z m 9.709,9.709 L 35,36.82 v 4.13 A 17.4236,17.4236 0 0 1 29.8,39.9381 Z M 56.5254,20.1494 A 0.9978,0.9978 0 0 0 55.5527,20.1055 L 45,25.3818 V 19 A 3.0033,3.0033 0 0 0 42,16 H 10 a 3.0033,3.0033 0 0 0 -3,3 v 26 a 3.0033,3.0033 0 0 0 3,3 h 32 a 3.0033,3.0033 0 0 0 3,-3 v -6.3818 l 10.5527,5.2763 A 1,1 0 0 0 57,43 V 21 A 1,1 0 0 0 56.5254,20.1494 Z M 43,45 a 1.0009,1.0009 0 0 1 -1,1 H 10 A 1.0013,1.0013 0 0 1 9,45 V 19 a 1.0013,1.0013 0 0 1 1,-1 h 32 a 1.0009,1.0009 0 0 1 1,1 z m 12,-3.6182 -10,-5 v -8.7636 l 10,-5 z"
id="path4"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB