diff --git a/doc/dev/reference/components/coturn.rst b/doc/dev/reference/components/coturn.rst new file mode 100644 index 000000000..95cf737ba --- /dev/null +++ b/doc/dev/reference/components/coturn.rst @@ -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: diff --git a/doc/dev/reference/components/index.rst b/doc/dev/reference/components/index.rst index a32ef3e7e..ec91f206c 100644 --- a/doc/dev/reference/components/index.rst +++ b/doc/dev/reference/components/index.rst @@ -16,6 +16,7 @@ Components letsencrypt staticfiles backups + coturn Base Classes ^^^^^^^^^^^^ diff --git a/plinth/modules/coturn/__init__.py b/plinth/modules/coturn/__init__.py index 73a180242..3fa9fb037 100644 --- a/plinth/modules/coturn/__init__.py +++ b/plinth/modules/coturn/__init__.py @@ -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) diff --git a/plinth/modules/coturn/components.py b/plinth/modules/coturn/components.py new file mode 100644 index 000000000..8b8b7372d --- /dev/null +++ b/plinth/modules/coturn/components.py @@ -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() diff --git a/plinth/modules/coturn/tests/test_components.py b/plinth/modules/coturn/tests/test_components.py new file mode 100644 index 000000000..0e3d0d719 --- /dev/null +++ b/plinth/modules/coturn/tests/test_components.py @@ -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