names: Allow adding multiple static domain names

- Change the mechanism for storing domain names in /etc/hosts. Don't write
hostname to /etc/hosts. Don't prepend hostname to domain name. This means that
when hostname changes, set_domain_name need not be called.

- This means that domain names such as example.fbx.one were not resolvable using
/etc/hosts but these will now resolve to 127.0.1.1. This is a minor concern to
becoming a breaking change.

- Don't use socket.getfqdn() for finding the domain name of the machine. Instead
read from /etc/hosts. There does not seem to a glibc/python API for querying
domain names from /etc/hosts with all variations it allows. Forward resolution
properly works no matter the library.

- Drop a pre-Python 3 conversion from unicode to ascii string for hostname. This
is no longer relevant.

- Domain name form is now domain add form. Passing domain name is mandatory.
Domain delete form and view have been introduced.

- Use augeas to edit hosts file. Add privileged methods to add/delete/get
domains. Add method to migration from old format to new. Support reading old
format too in get_domains.

Tests:

- Without hostname written in /etc/hosts, 'resolvectl query <hostname>' and
'ping <hostname>' work.

- With old /etc/hosts format apply patches and restart service. It will be
converted to new format.

- Adding a domain adds a new line to /etc/hosts file. The domain is shown in
domains list in Names app. Applications get reconfigured with the new domain
name.

- Deleting a domain adds a new line to /etc/hosts file. The domain is shown in
domains list in Names app. Applications get reconfigured with the new domain
name.

- Restarting app triggers domain added signal for all domains and all the
domains are shown in the Names app.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-01-17 15:08:01 -08:00 committed by James Valleroy
parent 889453daff
commit 863d170219
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
10 changed files with 223 additions and 100 deletions

View File

@ -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])

View File

@ -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)

View File

@ -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

View File

@ -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 <hostname>.<domain> <hostname>
New format:
127.0.1.1 <domain1>
127.0.1.1 <domain2>
"""
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

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<p>
{% blocktrans trimmed %}
App configurations will be updated.
{% endblocktrans %}
</p>
<form class="form form-delete" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-md btn-danger"
value="{% blocktrans %}Delete{% endblocktrans %}"/>
</form>
{% endblock %}

View File

@ -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()

View File

@ -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)

View File

@ -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<domain>[^/]+)/delete/$',
views.DomainDeleteView.as_view(), name='domain-delete'),
]

View File

@ -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)

View File

@ -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]
##############################