names: Introduce new API to manage domains

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2019-07-09 13:16:35 -07:00 committed by James Valleroy
parent b743305e06
commit 5cbdd40f6b
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
2 changed files with 387 additions and 0 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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'}