config, names: Move setting hostname from config to names

Tests:

- Config app description is as expected.
- Config form does not show hostname anymore.
  - Submitting the form with changes works.
- Names app has correct link for configuring Local Domain Name. Clicking it
  takes to page for setting hostname.
- Avahi shows the current .local domain correctly in Names app.
- Change hostname form shows correct value for current hostname.
- Change hostname form sets the value for hostname properly.
  - Page title is correct.
  - Validations works.
  - Pre/post hostname change signals are sent properly
  - Success message as shown expected
  - hostnamectl shows the set domain
- If domain name is not set, downloaded OpenVPN profile shows hostname.
- Unit tests work.
- Functional tests on names/config/avahi apps work.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
This commit is contained in:
Sunil Mohan Adapa 2024-09-08 16:39:26 -07:00 committed by Veiko Aasa
parent dceee56684
commit 8c69858d43
No known key found for this signature in database
GPG Key ID: 478539CAE680674E
16 changed files with 173 additions and 120 deletions

View File

@ -7,8 +7,8 @@ from plinth import app as app_module
from plinth import cfg, menu from plinth import cfg, menu
from plinth.daemon import Daemon from plinth.daemon import Daemon
from plinth.modules.backups.components import BackupRestore from plinth.modules.backups.components import BackupRestore
from plinth.modules.config import get_hostname
from plinth.modules.firewall.components import Firewall from plinth.modules.firewall.components import Firewall
from plinth.modules.names import get_hostname
from plinth.modules.names.components import DomainType from plinth.modules.names.components import DomainType
from plinth.package import Packages from plinth.package import Packages
from plinth.privileged import service as service_privileged from plinth.privileged import service as service_privileged
@ -58,7 +58,7 @@ class AvahiApp(app_module.App):
self.add(packages) self.add(packages)
domain_type = DomainType('domain-type-local', domain_type = DomainType('domain-type-local',
_('Local Network Domain'), 'config:index', _('Local Network Domain'), 'names:hostname',
can_have_certificate=False) can_have_certificate=False)
self.add(domain_type) self.add(domain_type)

View File

@ -20,7 +20,7 @@ from . import privileged
_description = [ _description = [
_('Here you can set some general configuration options ' _('Here you can set some general configuration options '
'like hostname, domain name, webserver home page etc.') 'like domain name, webserver home page etc.')
] ]
ADVANCED_MODE_KEY = 'advanced_mode' ADVANCED_MODE_KEY = 'advanced_mode'
@ -101,11 +101,6 @@ def get_domainname():
return '.'.join(fqdn.split('.')[1:]) return '.'.join(fqdn.split('.')[1:])
def get_hostname():
"""Return the hostname."""
return socket.gethostname()
def home_page_url2scid(url): def home_page_url2scid(url):
"""Return the shortcut ID of the given home page url.""" """Return the shortcut ID of the given home page url."""
# url is None when the freedombox-apache-homepage configuration file does # url is None when the freedombox-apache-homepage configuration file does

View File

@ -46,23 +46,6 @@ def get_homepage_choices():
class ConfigurationForm(forms.Form): class ConfigurationForm(forms.Form):
"""Main system configuration form""" """Main system configuration form"""
# See:
# https://tools.ietf.org/html/rfc952
# https://tools.ietf.org/html/rfc1035#section-2.3.1
# https://tools.ietf.org/html/rfc1123#section-2
# https://tools.ietf.org/html/rfc2181#section-11
hostname = forms.CharField(
label=gettext_lazy('Hostname'), help_text=format_lazy(
gettext_lazy(
'Hostname is the local name by which other devices on the '
'local network can reach your {box_name}. It must start and '
'end with an alphabet or a digit and have as interior '
'characters only alphabets, digits and hyphens. Total '
'length must be 63 characters or less.'),
box_name=gettext_lazy(cfg.box_name)), validators=[
validators.RegexValidator(HOSTNAME_REGEX,
gettext_lazy('Invalid hostname'))
], strip=True)
domainname = forms.CharField( domainname = forms.CharField(
label=gettext_lazy('Domain Name'), help_text=format_lazy( label=gettext_lazy('Domain Name'), help_text=format_lazy(

View File

@ -18,15 +18,6 @@ APACHE_HOMEPAGE_CONFIG = os.path.join(APACHE_CONF_ENABLED_DIR,
JOURNALD_FILE = pathlib.Path('/etc/systemd/journald.conf.d/50-freedombox.conf') JOURNALD_FILE = pathlib.Path('/etc/systemd/journald.conf.d/50-freedombox.conf')
@privileged
def set_hostname(hostname: str):
"""Set system hostname using hostnamectl."""
subprocess.run(
['hostnamectl', 'set-hostname', '--transient', '--static', hostname],
check=True)
action_utils.service_restart('avahi-daemon')
@privileged @privileged
def set_domainname(domainname: str | None = None): def set_domainname(domainname: str | None = None):
"""Set system domainname in /etc/hosts.""" """Set system domainname in /etc/hosts."""

View File

@ -15,34 +15,6 @@ from plinth.modules.config import (_home_page_scid2url, change_home_page,
from plinth.modules.config.forms import ConfigurationForm from plinth.modules.config.forms import ConfigurationForm
def test_hostname_field():
"""Test that hostname field accepts only valid hostnames."""
valid_hostnames = [
'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
'012345678901234567890123456789012345678901234567890123456789012'
]
invalid_hostnames = [
'', '-', '-a', 'a-', '.a', 'a.', 'a.a', '?', 'a?a',
'0123456789012345678901234567890123456789012345678901234567890123'
]
for hostname in valid_hostnames:
form = ConfigurationForm({
'hostname': hostname,
'domainname': 'example.com',
'logging_mode': 'volatile'
})
assert form.is_valid()
for hostname in invalid_hostnames:
form = ConfigurationForm({
'hostname': hostname,
'domainname': 'example.com',
'logging_mode': 'volatile'
})
assert not form.is_valid()
def test_domainname_field(): def test_domainname_field():
"""Test that domainname field accepts only valid domainnames.""" """Test that domainname field accepts only valid domainnames."""
valid_domainnames = [ valid_domainnames = [

View File

@ -19,12 +19,6 @@ def fixture_background(session_browser):
functional.login(session_browser) functional.login(session_browser)
def test_change_hostname(session_browser):
"""Test changing the hostname."""
functional.set_hostname(session_browser, 'mybox')
assert _get_hostname(session_browser) == 'mybox'
def test_change_domain_name(session_browser): def test_change_domain_name(session_browser):
"""Test changing the domain name.""" """Test changing the domain name."""
functional.set_domain_name(session_browser, 'mydomain.example') functional.set_domain_name(session_browser, 'mydomain.example')
@ -45,11 +39,6 @@ def test_change_home_page(session_browser):
assert _check_home_page_redirect(session_browser, 'plinth') assert _check_home_page_redirect(session_browser, 'plinth')
def _get_hostname(browser):
functional.nav_to_module(browser, 'config')
return browser.find_by_id('id_hostname').value
def _get_domain_name(browser): def _get_domain_name(browser):
functional.nav_to_module(browser, 'config') functional.nav_to_module(browser, 'config')
return browser.find_by_id('id_domainname').value return browser.find_by_id('id_domainname').value

View File

@ -8,8 +8,7 @@ from django.utils.translation import gettext as _
from plinth import views from plinth import views
from plinth.modules import config from plinth.modules import config
from plinth.signals import (domain_added, domain_removed, post_hostname_change, from plinth.signals import domain_added, domain_removed
pre_hostname_change)
from . import privileged from . import privileged
from .forms import ConfigurationForm from .forms import ConfigurationForm
@ -26,7 +25,6 @@ class ConfigAppView(views.AppView):
def get_initial(self): def get_initial(self):
"""Return the current status.""" """Return the current status."""
return { return {
'hostname': config.get_hostname(),
'domainname': config.get_domainname(), 'domainname': config.get_domainname(),
'homepage': config.get_home_page(), 'homepage': config.get_home_page(),
'advanced_mode': config.get_advanced_mode(), 'advanced_mode': config.get_advanced_mode(),
@ -40,17 +38,6 @@ class ConfigAppView(views.AppView):
is_changed = False is_changed = False
if old_status['hostname'] != new_status['hostname']:
try:
set_hostname(new_status['hostname'])
except Exception as exception:
messages.error(
self.request,
_('Error setting hostname: {exception}').format(
exception=exception))
else:
messages.success(self.request, _('Hostname set'))
if old_status['domainname'] != new_status['domainname']: if old_status['domainname'] != new_status['domainname']:
try: try:
set_domainname(new_status['domainname'], set_domainname(new_status['domainname'],
@ -100,29 +87,6 @@ class ConfigAppView(views.AppView):
return super().form_valid(form) return super().form_valid(form)
def set_hostname(hostname):
"""Set machine hostname and send signals before and after."""
old_hostname = config.get_hostname()
domainname = config.get_domainname()
# Hostname should be ASCII. If it's unicode but passed our
# valid_hostname check, convert
hostname = str(hostname)
pre_hostname_change.send_robust(sender='config', old_hostname=old_hostname,
new_hostname=hostname)
LOGGER.info('Changing hostname to - %s', hostname)
privileged.set_hostname(hostname)
LOGGER.info('Setting domain name after hostname change - %s', domainname)
privileged.set_domainname(domainname)
post_hostname_change.send_robust(sender='config',
old_hostname=old_hostname,
new_hostname=hostname)
def set_domainname(domainname, old_domainname): def set_domainname(domainname, old_domainname):
"""Set machine domain name to domainname.""" """Set machine domain name to domainname."""
old_domainname = config.get_domainname() old_domainname = config.get_domainname()

View File

@ -4,6 +4,7 @@ FreedomBox app to configure name services.
""" """
import logging import logging
import socket
import subprocess import subprocess
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -17,7 +18,8 @@ from plinth.diagnostic_check import (DiagnosticCheck,
from plinth.modules.backups.components import BackupRestore from plinth.modules.backups.components import BackupRestore
from plinth.package import Packages from plinth.package import Packages
from plinth.privileged import service as service_privileged from plinth.privileged import service as service_privileged
from plinth.signals import domain_added, domain_removed from plinth.signals import (domain_added, domain_removed, post_hostname_change,
pre_hostname_change)
from plinth.utils import format_lazy from plinth.utils import format_lazy
from . import components, manifest, privileged from . import components, manifest, privileged
@ -158,6 +160,36 @@ def on_domain_removed(sender, domain_type, name='', **kwargs):
###################################################### ######################################################
def get_hostname():
"""Return the hostname."""
return socket.gethostname()
def set_hostname(hostname):
"""Set machine hostname and send signals before and after."""
from plinth.modules import config
from plinth.modules.config import privileged as config_privileged
old_hostname = get_hostname()
domainname = config.get_domainname()
# 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)
logger.info('Changing hostname to - %s', hostname)
privileged.set_hostname(hostname)
logger.info('Setting domain name after hostname change - %s', domainname)
config_privileged.set_domainname(domainname)
post_hostname_change.send_robust(sender='names', old_hostname=old_hostname,
new_hostname=hostname)
def get_available_tls_domains(): def get_available_tls_domains():
"""Return an iterator with all domains able to have a certificate.""" """Return an iterator with all domains able to have a certificate."""
return (domain.name for domain in components.DomainName.list() return (domain.name for domain in components.DomainName.list()

View File

@ -2,10 +2,14 @@
"""Forms for the names app.""" """Forms for the names app."""
from django import forms from django import forms
from django.core import validators
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import cfg
from plinth.utils import format_lazy from plinth.utils import format_lazy
HOSTNAME_REGEX = r'^[a-zA-Z0-9]([-a-zA-Z0-9]{,61}[a-zA-Z0-9])?$'
class NamesConfigurationForm(forms.Form): class NamesConfigurationForm(forms.Form):
"""Form to configure names app.""" """Form to configure names app."""
@ -64,3 +68,22 @@ class NamesConfigurationForm(forms.Form):
'No. <p class="help-block">Do not verify domain name ' 'No. <p class="help-block">Do not verify domain name '
'resolutions.</p>', allow_markup=True)), 'resolutions.</p>', allow_markup=True)),
], initial='no') ], initial='no')
class HostnameForm(forms.Form):
"""Form to update system's hostname."""
# See:
# https://tools.ietf.org/html/rfc952
# https://tools.ietf.org/html/rfc1035#section-2.3.1
# https://tools.ietf.org/html/rfc1123#section-2
# https://tools.ietf.org/html/rfc2181#section-11
hostname = forms.CharField(
label=_('Hostname'), help_text=format_lazy(
_('Hostname is the local name by which other devices on the local '
'network can reach your {box_name}. It must start and end with '
'an alphabet or a digit and have as interior characters only '
'alphabets, digits and hyphens. Total length must be 63 '
'characters or less.'), box_name=_(cfg.box_name)), validators=[
validators.RegexValidator(HOSTNAME_REGEX,
_('Invalid hostname'))
], strip=True)

View File

@ -2,6 +2,7 @@
"""Configure Names App.""" """Configure Names App."""
import pathlib import pathlib
import subprocess
import augeas import augeas
@ -16,6 +17,15 @@ source_fallback_conf = pathlib.Path(
'/etc/systemd/resolved.conf.d/freedombox-fallback.conf') '/etc/systemd/resolved.conf.d/freedombox-fallback.conf')
@privileged
def set_hostname(hostname: str):
"""Set system hostname using hostnamectl."""
subprocess.run(
['hostnamectl', 'set-hostname', '--transient', '--static', hostname],
check=True)
action_utils.service_restart('avahi-daemon')
@privileged @privileged
def set_resolved_configuration(dns_fallback: bool | None = None, def set_resolved_configuration(dns_fallback: bool | None = None,
dns_over_tls: str | None = None, dns_over_tls: str | None = None,

View File

@ -0,0 +1,24 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for forms in names app."""
from ..forms import HostnameForm
def test_hostname_field():
"""Test that hostname field accepts only valid hostnames."""
valid_hostnames = [
'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
'012345678901234567890123456789012345678901234567890123456789012'
]
invalid_hostnames = [
'', '-', '-a', 'a-', '.a', 'a.', 'a.a', '?', 'a?a',
'0123456789012345678901234567890123456789012345678901234567890123'
]
for hostname in valid_hostnames:
form = HostnameForm({'hostname': hostname})
assert form.is_valid()
for hostname in invalid_hostnames:
form = HostnameForm({'hostname': hostname})
assert not form.is_valid()

View File

@ -0,0 +1,28 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Functional, browser based tests for names app."""
import pytest
from plinth.tests import functional
pytestmark = [
pytest.mark.system, pytest.mark.essential, pytest.mark.domain,
pytest.mark.names
]
@pytest.fixture(scope='module', autouse=True)
def fixture_background(session_browser):
"""Login."""
functional.login(session_browser)
def test_change_hostname(session_browser):
"""Test changing the hostname."""
functional.set_hostname(session_browser, 'mybox')
assert _get_hostname(session_browser) == 'mybox'
def _get_hostname(browser):
functional.visit(browser, '/plinth/sys/names/hostname/')
return browser.find_by_id('id_hostname-hostname').value

View File

@ -9,4 +9,6 @@ from . import views
urlpatterns = [ urlpatterns = [
re_path(r'^sys/names/$', views.NamesAppView.as_view(), name='index'), re_path(r'^sys/names/$', views.NamesAppView.as_view(), name='index'),
re_path(r'^sys/names/hostname/$', views.HostnameView.as_view(),
name='hostname'),
] ]

View File

@ -4,12 +4,15 @@ FreedomBox app for name services.
""" """
from django.contrib import messages from django.contrib import messages
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.edit import FormView
from plinth.modules import names
from plinth.views import AppView from plinth.views import AppView
from . import components, privileged, resolved from . import components, privileged, resolved
from .forms import NamesConfigurationForm from .forms import HostnameForm, NamesConfigurationForm
class NamesAppView(AppView): class NamesAppView(AppView):
@ -56,6 +59,40 @@ class NamesAppView(AppView):
return super().form_valid(form) return super().form_valid(form)
class HostnameView(FormView):
"""View to update system's hostname."""
template_name = 'form.html'
form_class = HostnameForm
prefix = 'hostname'
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 Hostname')
return context
def get_initial(self):
"""Return the values to fill in the form."""
initial = super().get_initial()
initial['hostname'] = names.get_hostname()
return initial
def form_valid(self, form):
"""Apply the form changes."""
if form.initial['hostname'] != form.cleaned_data['hostname']:
try:
names.set_hostname(form.cleaned_data['hostname'])
messages.success(self.request, _('Configuration updated'))
except Exception as exception:
messages.error(
self.request,
_('Error setting hostname: {exception}').format(
exception=exception))
return super().form_valid(form)
def get_status(): def get_status():
"""Get configured services per name.""" """Get configured services per name."""
domains = components.DomainName.list() domains = components.DomainName.list()

View File

@ -5,7 +5,7 @@ import logging
from django.http import HttpResponse from django.http import HttpResponse
from plinth.modules import config from plinth.modules import config, names
from plinth.views import AppView from plinth.views import AppView
from . import privileged from . import privileged
@ -26,7 +26,7 @@ def profile(request):
domainname = config.get_domainname() domainname = config.get_domainname()
if not config.get_domainname(): if not config.get_domainname():
domainname = config.get_hostname() domainname = names.get_hostname()
profile_string = privileged.get_profile(username, domainname) profile_string = privileged.get_profile(username, domainname)
response = HttpResponse(profile_string, response = HttpResponse(profile_string,

View File

@ -512,15 +512,18 @@ def running_inside_container():
return result.stdout.decode('utf-8').strip().lower() != 'none' return result.stdout.decode('utf-8').strip().lower() != 'none'
#############################
# System -> Names utilities #
#############################
def set_hostname(browser, hostname):
visit(browser, '/plinth/sys/names/hostname/')
browser.find_by_id('id_hostname-hostname').fill(hostname)
submit(browser, form_class='form-hostname')
############################## ##############################
# System -> Config utilities # # System -> Config utilities #
############################## ##############################
def set_hostname(browser, hostname):
nav_to_module(browser, 'config')
browser.find_by_id('id_hostname').fill(hostname)
submit(browser, form_class='form-configuration')
def set_advanced_mode(browser, mode): def set_advanced_mode(browser, mode):
nav_to_module(browser, 'config') nav_to_module(browser, 'config')
advanced_mode = browser.find_by_id('id_advanced_mode') advanced_mode = browser.find_by_id('id_advanced_mode')