mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
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:
parent
889453daff
commit
863d170219
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
25
plinth/modules/names/templates/names-domain-delete.html
Normal file
25
plinth/modules/names/templates/names-domain-delete.html
Normal 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 %}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
##############################
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user