From 5cbdd40f6b79ded12e56a9992cb03c600d094a53 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 9 Jul 2019 13:16:35 -0700 Subject: [PATCH] names: Introduce new API to manage domains Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/names/components.py | 234 ++++++++++++++++++ plinth/modules/names/tests/test_components.py | 153 ++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 plinth/modules/names/components.py create mode 100644 plinth/modules/names/tests/test_components.py diff --git a/plinth/modules/names/components.py b/plinth/modules/names/components.py new file mode 100644 index 000000000..f8540650d --- /dev/null +++ b/plinth/modules/names/components.py @@ -0,0 +1,234 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +App component to introduce a new domain type. +""" + +from django.utils.translation import ugettext_lazy as _ + +from plinth import app + +_SERVICES = { + '__all__': { + 'display_name': _('All'), + 'port': None + }, + 'http': { + 'display_name': _('All web apps'), + 'port': 80 + }, + 'https': { + 'display_name': _('All web apps'), + 'port': 443 + }, + 'ssh': { + 'display_name': _('Secure Shell'), + 'port': 22 + }, +} + + +class DomainType(app.FollowerComponent): + """Component to create a new type of domain. + + It serves the primary purpose of showing a entry in the domain names page. + This will allow users to discover the new type of domain and use the + providing app to create that type of domain. + + Similar to a menu entry, domain type information is available to the user + even when the corresponding app is disabled. + + """ + + _all = {} + + def __init__(self, component_id, display_name, configuration_url, + can_have_certificate=True): + """Initialize the domain type component. + + component_id should be a unique ID across all components of an app and + across all components. This will also act as the 'type' parameter for + each created domain. + + display_name is the type of domain displayed to the user in the + interface. + + configuration_url is the Django URL to which a user is redirected to in + order to create or manage a domain of this type. + + can_have_certificate indicates if this type of domain can have a TLS + certificate that can be validated by a typical browser. + + """ + super().__init__(component_id) + + self.display_name = display_name + self.configuration_url = configuration_url + self.can_have_certificate = can_have_certificate + + self._all[component_id] = self + + @classmethod + def get(cls, component_id): + """Return a component of given ID.""" + return cls._all[component_id] + + @classmethod + def list(cls): + """Return a list of all domain types.""" + return dict(cls._all) + + +class DomainName(app.FollowerComponent): + """Component to represent a domain name and its properties. + + Each domain name is necessarily attached to a domain type component that + must be created prior to creating the domain name. + + When an application providing or managing a domain name is disabled, the + corresponding domain name should become unavailable for others apps and + they must de-configure the domain name from app configuration. This is the + primary reason for making a domain name available as a component. + + """ + _all = {} + + def __init__(self, component_id, name, domain_type, services): + """Initialize a domain name. + + component_id should be a unique ID across all components of an app and + across all components. The value is typically 'domain-{app}-{domain}'. + This ensures that if the same domain is managed by multiple apps, it is + available as multiple instances. When one instance is removed, say by + disabling that app, the other instance will still provide that domain. + + name is the domain name that is being represented by the component. + This should be fully qualified domain name. + + domain_type should be a string representing the type of the domain. + This is the component ID of the domain type earlier registered by the + app that is creating the domain name component. + + services is a list of string identifiers for services potentially + provided by the domain. For example, 'ssh' for secure shell service + provided on port 22. It is used for showing information to the user and + to retrieve a list of a domains that an app may use. + + A service value can also be an integer in which case it will be + converted to a string by looking up a list of known services. This + process is not perfect and may cause problems when filtering domains + that could potentially provide a service. + + The most common value of a services parameter is the string '__all__' + indicating that the domain can potentially provide any service without + limitations due to the nature of the domain name. + + """ + super().__init__(component_id) + + self.name = name + self.domain_type = DomainType.get(domain_type) + self._services = DomainName._normalize_services(services) + + self._all[component_id] = self + + @property + def services(self): + """Read-only property to get the list of services.""" + return self._services + + @staticmethod + def _normalize_services(services): + """If ports numbers are provided convert them to service IDs.""" + if services == '__all__': + return services + + return [DomainName._normalize_service(service) for service in services] + + @staticmethod + def _normalize_service(service): + """Return the service ID for a given port number. + + XXX: Eliminate this and use a generalized approach eventually. + + """ + if isinstance(service, str): + return service + + if not isinstance(service, int): + raise ValueError('Invalid service') + + for service_id, description in _SERVICES.items(): + if description['port'] == service: + return service_id + + return str(service) + + def get_readable_services(self): + """Return list of unique service strings that can be shown to user.""" + services = self.services + if self.services == '__all__': + services = [services] + + return { + _SERVICES.get(service, {'display_name': service})['display_name'] + for service in services + } + + def has_service(self, service): + """Return whether a service is available for this domain name.""" + return (service is None or self.services == '__all__' + or service in self.services) + + def remove(self): + """Remove the domain name from global list of domains. + + It is acceptable to call remove() multiple times. + + """ + try: + del self._all[self.component_id] + except KeyError: + pass + + @classmethod + def get(cls, component_id): + """Return the domain name object given name and app.""" + return cls._all[component_id] + + @classmethod + def list(cls, filter_for_service=None): + """Return list of domains.""" + return [ + domain for domain in cls._all.values() + if domain.has_service(filter_for_service) + ] + + @classmethod + def list_names(cls, filter_for_service=None): + """Return a set of unique domain names. + + Multiple different components may provide the same domain name. This + method could be used to retrieve a list of all domain names without + duplication. + + """ + return { + domain.name + for domain in cls._all.values() + if domain.has_service(filter_for_service) + } diff --git a/plinth/modules/names/tests/test_components.py b/plinth/modules/names/tests/test_components.py new file mode 100644 index 000000000..b36d0884d --- /dev/null +++ b/plinth/modules/names/tests/test_components.py @@ -0,0 +1,153 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Test the App components provides by names app. +""" + +import pytest + +from ..components import DomainName, DomainType + + +@pytest.fixture(name='domain_type') +def fixture_domain_type(): + """Fixture to create a domain type after clearing all existing ones.""" + DomainType._all = {} + return DomainType('test-domain-type', 'x-display-name', 'config_url') + + +@pytest.fixture(name='domain_name') +def fixture_domain_name(domain_type): + DomainName._all = {} + return DomainName('test-domain-name', 'test.example.com', + 'test-domain-type', ['http', 443, 'ssh', 9000]) + + +def test_domain_type_init(): + """Test initialization of domain type object.""" + component = DomainType('test-component', 'x-display-name', 'config_url') + assert component.component_id == 'test-component' + assert component.display_name == 'x-display-name' + assert component.configuration_url == 'config_url' + assert component.can_have_certificate + assert len(DomainType._all) + assert DomainType._all['test-component'] == component + + component = DomainType('test-component', 'x-display-name', 'config_url', + can_have_certificate=False) + assert not component.can_have_certificate + + +def test_domain_type_get(domain_type): + """Test getting domain type object.""" + assert DomainType.get('test-domain-type') == domain_type + with pytest.raises(KeyError): + DomainType.get('unknown-domain-type') + + +def test_domain_type_list(domain_type): + """Test listing of all domain types.""" + assert DomainType.list()['test-domain-type'] == domain_type + assert id(DomainType.list()) != id(DomainType._all) + + +def test_domain_name_init(domain_type): + """Test initializing a domain name.""" + component = DomainName('test-component', 'test.example.com', + 'test-domain-type', '__all__') + + assert component.component_id == 'test-component' + assert component.name == 'test.example.com' + assert component.domain_type == domain_type + assert component.services == '__all__' + assert len(DomainName._all) + assert DomainName._all['test-component'] == component + + +def test_domain_name_service_normalization(domain_name): + """Test that passed services get normalized during initialization.""" + assert set(domain_name.services) == {'http', 'https', 'ssh', '9000'} + + +def test_domain_name_getting_readable_services(domain_name): + """Test that getting readable string for services works""" + strings = [str(string) for string in domain_name.get_readable_services()] + assert sorted(strings) == sorted(['All web apps', 'Secure Shell', '9000']) + + +def test_domain_name_has_service(domain_name): + """Test checking if a domain name provides a service.""" + assert domain_name.has_service('http') + assert domain_name.has_service('https') + assert domain_name.has_service('9000') + assert not domain_name.has_service('1234') + assert domain_name.has_service(None) + + domain_name._services = '__all__' + assert domain_name.has_service('1234') + + +def test_domain_name_remove(domain_name): + """Test removing a domain name from global list.""" + domain_name.remove() + assert 'test-domain-name' not in DomainName._all + + domain_name.remove() + + +def test_domain_name_get(domain_name): + """Test retrieving a domain name using component ID.""" + assert DomainName.get('test-domain-name') == domain_name + + with pytest.raises(KeyError): + DomainName.get('unknown-domain-name') + + +def test_domain_name_list(domain_name): + """Test that retrieving list of domain name objects.""" + domain_name2 = DomainName('test-domain-name2', 'test.example.com', + 'test-domain-type', '__all__') + domains = DomainName.list() + assert len(domains) == 2 + assert domain_name in domains + assert domain_name2 in domains + + domains = DomainName.list('http') + assert len(domains) == 2 + assert domain_name in domains + assert domain_name2 in domains + + domains = DomainName.list('unknown') + assert len(domains) == 1 + assert domain_name not in domains + assert domain_name2 in domains + + +def test_domain_name_list_names(domain_name): + """Test that retrieving list of unique domain names works.""" + DomainName('test-domain-name2', 'test.example.com', 'test-domain-type', + ['http']) + DomainName('test-domain-name3', 'test3.example.com', 'test-domain-type', + '__all__') + domains = DomainName.list_names() + assert domains == {'test.example.com', 'test3.example.com'} + + domains = DomainName.list_names('http') + assert domains == {'test.example.com', 'test3.example.com'} + + domains = DomainName.list_names('unknown') + assert domains == {'test3.example.com'}