coturn: Add new component for usage of coturn by other apps

Sunil:

 - Add TurnConfiguration to reference documentation. Add more details in
 docstrings.

 - Rename the component to TurnConsumer since 'Component' in the name is
 redundant and unconventional. Also, hopefully, the component will retain the
 API over multiple TURN servers.

 - Log when notifying other components about configuration change.

 - Use TurnConfiguration class more widely.

 - Refactor for simplicity.

 - Additional tests.

 - Move URI generation code into TurnConfiguration.

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Joseph Nuthalapati 2021-01-31 22:30:15 +05:30 committed by Sunil Mohan Adapa
parent db993ecb16
commit ceacde67b9
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
5 changed files with 194 additions and 3 deletions

View File

@ -0,0 +1,10 @@
.. SPDX-License-Identifier: CC-BY-SA-4.0
Coturn
^^^^^^
.. autoclass:: plinth.modules.coturn.components.TurnConsumer
:members:
.. autoclass:: plinth.modules.coturn.components.TurnConfiguration
:members:

View File

@ -16,6 +16,7 @@ Components
letsencrypt
staticfiles
backups
coturn
Base Classes
^^^^^^^^^^^^

View File

@ -4,6 +4,7 @@ FreedomBox app to configure Coturn server.
"""
import json
import logging
import pathlib
from django.utils.translation import ugettext_lazy as _
@ -14,6 +15,7 @@ from plinth import menu
from plinth.daemon import Daemon
from plinth.modules import names
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
@ -39,6 +41,8 @@ _description = [
app = None
logger = logging.getLogger(__name__)
class CoturnApp(app_module.App):
"""FreedomBox app for Coturn."""
@ -98,6 +102,7 @@ def setup(helper, old_version=None):
helper.call('post', actions.superuser_run, 'coturn', ['setup'])
helper.call('post', app.enable)
app.get_component('letsencrypt-coturn').setup_certificates()
notify_configuration_change()
def get_available_domains():
@ -109,8 +114,8 @@ def get_available_domains():
def get_domain():
"""Read TLS domain from config file select first available if none."""
config = get_config()
if config['realm']:
return get_config()['realm']
if config.domain:
return config.domain
domain = next(get_available_domains(), None)
set_domain(domain)
@ -135,9 +140,19 @@ def set_domain(domain):
"""Set the TLS domain by writing a file to data directory."""
if domain:
actions.superuser_run('coturn', ['set-domain', domain])
notify_configuration_change()
def get_config():
"""Return the coturn server configuration."""
output = actions.superuser_run('coturn', ['get-config'])
return json.loads(output)
config = json.loads(output)
return TurnConfiguration(config['realm'], [], config['static_auth_secret'])
def notify_configuration_change():
"""Notify all coturn components about the new configuration."""
logger.info('Notifying STUN/TURN consumers about configuration change')
config = get_config()
for component in TurnConsumer.list():
component.on_config_change(config)

View File

@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""App component for other apps to manage their STUN/TURN server configuration.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import List
from plinth import app
@dataclass
class TurnConfiguration:
"""Data class to hold TURN server configuration.
domain is the string representing the domain name with which Coturn has
been configured. This is necessary to associate the correct TLS certificate
with Coturn communication. STUN/TURN URIs are generated using this.
shared_secret is a string that must be used by a server to be accepted by
Coturn server. This is the value set in Coturn configuration file.
uris are a list of strings that represent the full set of STUN/TURN URIs
that must be used by a STUN/TURN client after advice from the server.
"""
domain: str = None
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']
]
def to_json(self) -> str:
"""Return a JSON representation of the configuration."""
return json.dumps({
'domain': self.domain,
'uris': self.uris,
'shared_secret': self.shared_secret
})
class TurnConsumer(app.FollowerComponent):
"""Component to manage coturn configuration.
In order to provide audio/video calling functionality, communication
servers very often use an external server such as Coturn for implementing
the STUN/TURN protocol. To use Coturn, the server needs to be configured
with a set of URIs provided by Coturn along with a shared secret.
This component when added to an app allows the app to retrieve the current
Coturn configuration and respond to any future configuration changes.
"""
_all = {}
def __init__(self, component_id):
"""Initialize the component.
component_id should be a unique ID across all components of an app and
across all components.
"""
super().__init__(component_id)
self._all[component_id] = self
@classmethod
def list(cls) -> List[TurnConsumer]:
"""Return a list of all Coturn components."""
return cls._all.values()
def on_config_change(self, config: TurnConfiguration):
"""Add or update STUN/TURN configuration.
Override this method and change app's configuration.
"""
def get_configuration(self) -> TurnConfiguration:
"""Return current coturn configuration."""
from plinth.modules import coturn
return coturn.get_config()

View File

@ -0,0 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Tests for the Coturn app component.
"""
from unittest.mock import call, patch
import pytest
from plinth.utils import random_string
from .. import notify_configuration_change
from ..components import TurnConfiguration, TurnConsumer
@pytest.fixture(name='turn_configuration')
def fixture_turn_configuration():
"""Return test Coturn configuration."""
return TurnConfiguration('test-domain.example', [], random_string(64))
@pytest.fixture(name='empty_component_list', autouse=True)
def fixture_empty_component_list():
"""Remove all entries in component list before starting a test."""
TurnConsumer._all = {}
def test_configuration_init():
"""Test creating configuration object."""
config = TurnConfiguration('test-domain.example', [], 'test-shared-secret')
assert config.domain == 'test-domain.example'
assert config.shared_secret == 'test-shared-secret'
assert config.uris == [
"stun:test-domain.example:3478?transport=tcp",
"stun:test-domain.example:3478?transport=udp",
"turn:test-domain.example:3478?transport=tcp",
"turn:test-domain.example:3478?transport=udp",
]
config = TurnConfiguration(None, ['test-uri1', 'test-uri2'],
'test-shared-secret')
assert config.domain is None
assert config.uris == ['test-uri1', 'test-uri2']
config = TurnConfiguration('test-domain.example',
['test-uri1', 'test-uri2'],
'test-shared-secret')
assert config.domain == 'test-domain.example'
assert config.uris == ['test-uri1', 'test-uri2']
def test_component_init_and_list():
"""Test initializing and listing all the components."""
component1 = TurnConsumer('component1')
component2 = TurnConsumer('component2')
assert component1.component_id == 'component1'
assert [component1, component2] == list(TurnConsumer.list())
@patch('plinth.modules.coturn.get_config')
def test_notify_on_configuration_changed(get_config, turn_configuration):
"""Test configuration change notifications."""
component = TurnConsumer('component')
get_config.return_value = turn_configuration
with patch.object(component, 'on_config_change') as mock_method:
notify_configuration_change()
mock_method.assert_has_calls([call(turn_configuration)])
@patch('plinth.modules.coturn.get_config')
def test_get_configuration(get_config, turn_configuration):
"""Test coturn configuration retrieval using component."""
get_config.return_value = turn_configuration
component = TurnConsumer('component')
assert component.get_configuration() == turn_configuration