diff --git a/plinth/modules/ejabberd/tests/test_functional.py b/plinth/modules/ejabberd/tests/test_functional.py index 5ecb1d9c7..101146c89 100644 --- a/plinth/modules/ejabberd/tests/test_functional.py +++ b/plinth/modules/ejabberd/tests/test_functional.py @@ -133,7 +133,8 @@ def _jsxc_login(browser): def _jsxc_add_contact(browser): """Add a contact to JSXC user's roster.""" - functional.set_domain_name(browser, 'localhost') + # Configure a static domain + functional.domain_add(browser, 'mydomain.example') functional.install(browser, 'jsxc') _jsxc_login(browser) functional.eventually(_is_jsxc_buddy_list_loaded, args=[browser]) diff --git a/plinth/modules/names/__init__.py b/plinth/modules/names/__init__.py index 0d0337ad7..3f55af1df 100644 --- a/plinth/modules/names/__init__.py +++ b/plinth/modules/names/__init__.py @@ -5,7 +5,6 @@ FreedomBox app to configure name services. import logging import pathlib -import socket import subprocess from django.utils.translation import gettext_lazy as _ @@ -43,7 +42,7 @@ class NamesApp(app_module.App): app_id = 'names' - _version = 2 + _version = 3 can_be_disabled = False @@ -66,7 +65,8 @@ class NamesApp(app_module.App): self.add(packages) domain_type = DomainType('domain-type-static', _('Domain Name'), - configuration_url='names:domains', + delete_url='names:domain-delete', + add_url='names:domain-add', can_have_certificate=True) self.add(domain_type) @@ -84,11 +84,10 @@ class NamesApp(app_module.App): domain_removed.connect(on_domain_removed) # Register domain with Name Services module. - domain_name = get_domain_name() - if domain_name: + for domain in privileged.get_domains(): domain_added.send_robust(sender='names', domain_type='domain-type-static', - name=domain_name, services='__all__') + name=domain, services='__all__') # Schedule installation of systemd-resolved if not already installed. if not is_resolved_installed(): @@ -115,6 +114,9 @@ class NamesApp(app_module.App): except Exception: pass + if old_version < 3: + privileged.domains_migrate() + if is_resolved_installed(): # Fresh install or upgrading to version 2 if old_version < 2: @@ -235,10 +237,10 @@ def on_domain_removed(sender, domain_type, name='', **kwargs): ###################################################### -def get_domain_name(): +def get_domain_name() -> str | None: """Return the currently set static domain name.""" - fqdn = socket.getfqdn() - return '.'.join(fqdn.split('.')[1:]) + domains = privileged.get_domains() + return domains[0] if domains else '' def get_hostname(): @@ -251,11 +253,6 @@ def get_hostname(): def set_hostname(hostname): """Set machine hostname and send signals before and after.""" old_hostname = get_hostname() - domain_name = get_domain_name() - - # Hostname should be ASCII. If it's unicode but passed our - # valid_hostname check, convert - hostname = str(hostname) pre_hostname_change.send_robust(sender='names', old_hostname=old_hostname, new_hostname=hostname) @@ -263,9 +260,6 @@ def set_hostname(hostname): logger.info('Changing hostname to - %s', hostname) privileged.set_hostname(hostname) - logger.info('Setting domain name after hostname change - %s', domain_name) - privileged.set_domain_name(domain_name) - post_hostname_change.send_robust(sender='names', old_hostname=old_hostname, new_hostname=hostname) diff --git a/plinth/modules/names/forms.py b/plinth/modules/names/forms.py index 3aa99bbf5..455626931 100644 --- a/plinth/modules/names/forms.py +++ b/plinth/modules/names/forms.py @@ -99,8 +99,8 @@ def _domain_label_validator(domain_name): raise ValidationError(_('Invalid domain name')) -class DomainNameForm(forms.Form): - """Form to update system's static domain name.""" +class DomainAddForm(forms.Form): + """Form to add a static domain name.""" domain_name = forms.CharField( label=_('Domain Name'), help_text=format_lazy( @@ -111,7 +111,7 @@ class DomainNameForm(forms.Form): 'only alphabets, digits and hyphens. Length of each label ' 'must be 63 characters or less. Total length of domain name ' 'must be 253 characters or less.'), box_name=_(cfg.box_name)), - required=False, validators=[ + required=True, validators=[ validators.RegexValidator( r'^[a-zA-Z0-9]([-a-zA-Z0-9.]{,251}[a-zA-Z0-9])?$', _('Invalid domain name')), _domain_label_validator diff --git a/plinth/modules/names/privileged.py b/plinth/modules/names/privileged.py index d113f11b5..d889f4d15 100644 --- a/plinth/modules/names/privileged.py +++ b/plinth/modules/names/privileged.py @@ -16,6 +16,8 @@ source_fallback_conf = pathlib.Path( '/usr/share/freedombox' '/etc/systemd/resolved.conf.d/freedombox-fallback.conf') +HOSTS_LOCAL_IP = '127.0.1.1' + @privileged def set_hostname(hostname: str): @@ -26,30 +28,97 @@ def set_hostname(hostname: str): action_utils.service_restart('avahi-daemon') +def _load_augeas_hosts(): + """Initialize Augeas for editing /etc/hosts.""" + hosts_file = '/etc/hosts' + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.transform('hosts', hosts_file) + aug.set('/augeas/context', '/files' + hosts_file) + aug.load() + return aug + + +def get_domains(aug=None) -> list[str]: + """Return the list of domains.""" + if not aug: + aug = _load_augeas_hosts() + + domains = {} # Maintain order of entries + for match in aug.match('*'): + if aug.get(match + '/ipaddr') != HOSTS_LOCAL_IP: + continue + + domain = aug.get(match + '/canonical') + aliases = [] + for alias_match in aug.match(match + '/alias'): + aliases.append(aug.get(alias_match)) + + # Read old style domains too. + if aliases and '.' not in aliases[-1]: + hostname = aliases[-1] + if domain.startswith(hostname + '.'): + domain = domain.partition('.')[2] + aliases = aliases[:-1] + + for value in [domain] + aliases: + if value: + domains[domain] = True + + return list(domains.keys()) + + @privileged -def set_domain_name(domain_name: str | None = None): +def domain_add(domain_name: str | None = None): """Set system's static domain name in /etc/hosts.""" - hostname = subprocess.check_output(['hostname']).decode().strip() - hosts_path = pathlib.Path('/etc/hosts') - if domain_name: - insert_line = f'127.0.1.1 {hostname}.{domain_name} {hostname}\n' - else: - insert_line = f'127.0.1.1 {hostname}\n' + aug = _load_augeas_hosts() + domains = get_domains(aug) + if domain_name in domains: + return # Domain already present in /etc/hosts - lines = hosts_path.read_text(encoding='utf-8').splitlines(keepends=True) - new_lines = [] - found = False - for line in lines: - if '127.0.1.1' in line: - new_lines.append(insert_line) - found = True - else: - new_lines.append(line) + aug.set('./01/ipaddr', HOSTS_LOCAL_IP) + aug.set('./01/canonical', domain_name) + aug.save() - if not found: - new_lines.append(insert_line) - hosts_path.write_text(''.join(new_lines), encoding='utf-8') +@privileged +def domain_delete(domain_name: str | None = None): + """Set system's static domain name in /etc/hosts.""" + aug = _load_augeas_hosts() + domains = get_domains(aug) + if domain_name not in domains: + return # Domain already not present in /etc/hosts + + for match in aug.match('*'): + if aug.get(match + '/ipaddr') == HOSTS_LOCAL_IP and \ + aug.get(match + '/canonical') == domain_name: + aug.remove(match) + + aug.save() + + +@privileged +def domains_migrate() -> None: + """Convert old style of adding domain names to /etc/hosts to new. + + Old format: + 127.0.1.1 . + + New format: + 127.0.1.1 + 127.0.1.1 + """ + aug = _load_augeas_hosts() + domains = get_domains(aug) + for match in aug.match('*'): + if aug.get(match + '/ipaddr') == HOSTS_LOCAL_IP: + aug.remove(match) + + for number, domain in enumerate(domains): + aug.set(f'./0{number}/ipaddr', HOSTS_LOCAL_IP) + aug.set(f'./0{number}/canonical', domain) + + aug.save() @privileged diff --git a/plinth/modules/names/templates/names-domain-delete.html b/plinth/modules/names/templates/names-domain-delete.html new file mode 100644 index 000000000..9affc6ead --- /dev/null +++ b/plinth/modules/names/templates/names-domain-delete.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +

+ {% blocktrans trimmed %} + App configurations will be updated. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/names/tests/test_forms.py b/plinth/modules/names/tests/test_forms.py index 03c633698..17fe2591c 100644 --- a/plinth/modules/names/tests/test_forms.py +++ b/plinth/modules/names/tests/test_forms.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Tests for forms in names app.""" -from ..forms import DomainNameForm, HostnameForm +from ..forms import DomainAddForm, HostnameForm def test_hostname_field(): @@ -27,20 +27,20 @@ def test_hostname_field(): def test_domain_name_field(): """Test that domain name field accepts only valid domain names.""" valid_domain_names = [ - '', 'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example', + 'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example', 'example.org', 'a.b.c.d', 'a-0.b-0.c-0', '012345678901234567890123456789012345678901234567890123456789012', ((('x' * 63) + '.') * 3) + 'x' * 61 ] invalid_domain_names = [ - '-', '-a', 'a-', '.a', 'a.', '?', 'a?a', 'a..a', 'a.-a', '.', + '', '-', '-a', 'a-', '.a', 'a.', '?', 'a?a', 'a..a', 'a.-a', '.', ((('x' * 63) + '.') * 3) + 'x' * 62, 'x' * 64 ] for domain_name in valid_domain_names: - form = DomainNameForm({'domain_name': domain_name}) + form = DomainAddForm({'domain_name': domain_name}) assert form.is_valid() for domain_name in invalid_domain_names: - form = DomainNameForm({'domain_name': domain_name}) + form = DomainAddForm({'domain_name': domain_name}) assert not form.is_valid() diff --git a/plinth/modules/names/tests/test_functional.py b/plinth/modules/names/tests/test_functional.py index f29328646..4e14d92da 100644 --- a/plinth/modules/names/tests/test_functional.py +++ b/plinth/modules/names/tests/test_functional.py @@ -30,14 +30,11 @@ def _get_hostname(browser): def test_change_domain_name(session_browser): """Test changing the domain name.""" - functional.set_domain_name(session_browser, 'mydomain.example') - assert _get_domain_name(session_browser) == 'mydomain.example' + functional.domain_remove(session_browser, 'mydomain.example') + functional.domain_add(session_browser, 'mydomain.example') + assert 'mydomain.example' in functional.domain_list(session_browser) # Capitalization is ignored. - functional.set_domain_name(session_browser, 'Mydomain.example') - assert _get_domain_name(session_browser) == 'mydomain.example' - - -def _get_domain_name(browser): - functional.visit(browser, '/plinth/sys/names/domains/') - return browser.find_by_id('id_domain-name-domain_name').value + functional.domain_remove(session_browser, 'mydomain2.example') + functional.domain_add(session_browser, 'Mydomain2.example') + assert 'mydomain2.example' in functional.domain_list(session_browser) diff --git a/plinth/modules/names/urls.py b/plinth/modules/names/urls.py index 7f8fe3b17..82d4aca3d 100644 --- a/plinth/modules/names/urls.py +++ b/plinth/modules/names/urls.py @@ -1,7 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -URLs for the name services module -""" +"""URLs for the name services module.""" from django.urls import re_path @@ -11,6 +9,8 @@ urlpatterns = [ re_path(r'^sys/names/$', views.NamesAppView.as_view(), name='index'), re_path(r'^sys/names/hostname/$', views.HostnameView.as_view(), name='hostname'), - re_path(r'^sys/names/domains/$', views.DomainNameView.as_view(), - name='domains'), + re_path(r'^sys/names/domains/$', views.DomainAddView.as_view(), + name='domain-add'), + re_path(r'^sys/names/domains/(?P[^/]+)/delete/$', + views.DomainDeleteView.as_view(), name='domain-delete'), ] diff --git a/plinth/modules/names/views.py b/plinth/modules/names/views.py index 102c8b496..319a4671d 100644 --- a/plinth/modules/names/views.py +++ b/plinth/modules/names/views.py @@ -6,8 +6,10 @@ FreedomBox app for name services. import logging from django.contrib import messages +from django.shortcuts import redirect from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView from django.views.generic.edit import FormView from plinth.modules import names @@ -15,7 +17,7 @@ from plinth.signals import domain_added, domain_removed from plinth.views import AppView from . import components, privileged, resolved -from .forms import DomainNameForm, HostnameForm, NamesConfigurationForm +from .forms import DomainAddForm, HostnameForm, NamesConfigurationForm logger = logging.getLogger(__name__) @@ -102,40 +104,46 @@ class HostnameView(FormView): return super().form_valid(form) -class DomainNameView(FormView): +class DomainAddView(FormView): """View to update system's static domain name.""" template_name = 'form.html' - form_class = DomainNameForm - prefix = 'domain-name' + form_class = DomainAddForm + prefix = 'domain-add' success_url = reverse_lazy('names:index') def get_context_data(self, **kwargs): """Return additional context for rendering the template.""" context = super().get_context_data(**kwargs) - context['title'] = _('Set Domain Name') + context['title'] = _('Add Domain Name') return context - def get_initial(self): - """Return the values to fill in the form.""" - initial = super().get_initial() - initial['domain_name'] = names.get_domain_name() - return initial - def form_valid(self, form): """Apply the form changes.""" - if form.initial['domain_name'] != form.cleaned_data['domain_name']: - try: - set_domain_name(form.cleaned_data['domain_name']) - messages.success(self.request, _('Configuration updated')) - except Exception as exception: - messages.error( - self.request, - _('Error setting domain name: {exception}').format( - exception=exception)) - + _domain_add(form.cleaned_data['domain_name']) + messages.success(self.request, _('Configuration updated')) return super().form_valid(form) +class DomainDeleteView(TemplateView): + """Confirm and delete a domain.""" + template_name = 'names-domain-delete.html' + + def get_context_data(self, **kwargs): + """Return additional context data for rendering the template.""" + context = super().get_context_data(**kwargs) + domain = self.kwargs['domain'] + context['domain'] = domain + context['title'] = str( + _('Delete Domain {domain}?')).format(domain=domain) + return context + + def post(self, request, domain): + """Delete a domain.""" + _domain_delete(domain) + messages.success(request, _('Domain deleted.')) + return redirect('names:index') + + def get_status(): """Get configured services per name.""" domains = components.DomainName.list() @@ -148,24 +156,29 @@ def get_status(): return {'domains': domains, 'unused_domain_types': unused_domain_types} -def set_domain_name(domain_name): - """Set system's static domain name to domain_name.""" - old_domain_name = names.get_domain_name() - +def _domain_add(domain_name: str): + """Add a static domain name.""" # Domain name is not case sensitive, but Let's Encrypt certificate # paths use lower-case domain name. domain_name = domain_name.lower() - logger.info('Changing domain name to - %s', domain_name) - privileged.set_domain_name(domain_name) + logger.info('Adding domain name - %s', domain_name) + privileged.domain_add(domain_name) + + domain_added.send_robust(sender='names', domain_type='domain-type-static', + name=domain_name, services='__all__') + + +def _domain_delete(domain_name: str): + """Remove a static domain name.""" + # Domain name is not case sensitive, but Let's Encrypt certificate + # paths use lower-case domain name. + domain_name = domain_name.lower() + + logger.info('Removing domain name - %s', domain_name) + privileged.domain_delete(domain_name) # Update domain registered with Name Services module. - if old_domain_name: - domain_removed.send_robust(sender='names', - domain_type='domain-type-static', - name=old_domain_name) - - if domain_name: - domain_added.send_robust(sender='names', - domain_type='domain-type-static', - name=domain_name, services='__all__') + domain_removed.send_robust(sender='names', + domain_type='domain-type-static', + name=domain_name) diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index dc7cec711..a6f5e7689 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -592,16 +592,40 @@ def running_inside_container(): ############################# # System -> Names utilities # ############################# -def set_hostname(browser, hostname): +def set_hostname(browser, hostname: str): + """Configure the system hostname.""" visit(browser, '/plinth/sys/names/hostname/') browser.find_by_id('id_hostname-hostname').fill(hostname) submit(browser, form_class='form-hostname') -def set_domain_name(browser, domain_name): - visit(browser, '/plinth/sys/names/domains/') - browser.find_by_id('id_domain-name-domain_name').fill(domain_name) - submit(browser, form_class='form-domain-name') +def domain_add(browser, domain_name: str): + """Add a domain to list of domains.""" + if domain_name in domain_list(browser): + return + + visit(browser, '/plinth/sys/names/') + click_link_by_href(browser, '/plinth/sys/names/domains/') + browser.find_by_id('id_domain-add-domain_name').fill(domain_name) + submit(browser, form_class='form-domain-add') + + +def domain_remove(browser, domain_name: str): + """Remove a domain from list of domains.""" + if domain_name not in domain_list(browser): + return + + visit(browser, '/plinth/sys/names/') + click_link_by_href(browser, + f'/plinth/sys/names/domains/{domain_name}/delete/') + submit(browser, form_class='form-delete') + + +def domain_list(browser) -> list[str]: + """Return a list of domains configured.""" + visit(browser, '/plinth/sys/names/') + elements = browser.find_by_css('td.names-domain-column') + return [element.text for element in elements] ##############################