diff --git a/doc/dev/reference/components/webserver.rst b/doc/dev/reference/components/webserver.rst index 896d66cca..987594342 100644 --- a/doc/dev/reference/components/webserver.rst +++ b/doc/dev/reference/components/webserver.rst @@ -6,5 +6,8 @@ Webserver .. autoclass:: plinth.modules.apache.components.Webserver :members: +.. autoclass:: plinth.modules.apache.components.WebserverRoot + :members: + .. autoclass:: plinth.modules.apache.components.Uwsgi :members: diff --git a/plinth/modules/apache/components.py b/plinth/modules/apache/components.py index 81f770e6b..64df03437 100644 --- a/plinth/modules/apache/components.py +++ b/plinth/modules/apache/components.py @@ -6,7 +6,7 @@ import subprocess from django.utils.translation import gettext_noop -from plinth import action_utils, app +from plinth import action_utils, app, kvstore from plinth.diagnostic_check import (DiagnosticCheck, DiagnosticCheckParameters, Result) from plinth.privileged import service as service_privileged @@ -17,8 +17,9 @@ from . import privileged class Webserver(app.LeaderComponent): """Component to enable/disable Apache configuration.""" - def __init__(self, component_id, web_name, kind='config', urls=None, - expect_redirects=False, last_updated_version=None): + def __init__(self, component_id: str, web_name: str, kind: str = 'config', + urls: list[str] | None = None, expect_redirects: bool = False, + last_updated_version: int | None = None): """Initialize the web server component. component_id should be a unique ID across all components of an app and @@ -35,6 +36,9 @@ class Webserver(app.LeaderComponent): urls is a list of URLs over which a HTTP services will be available due to this component. This list is only used for running diagnostics. + expect_redirects is a boolean that allows redirects when trying to + access the URLs during diagnosis of the component. + last_updated_version is the app version in which the web server configuration/site/module file was updated. Using this, web server will be automatically reloaded or restarted as necessary during app upgrade. @@ -47,15 +51,15 @@ class Webserver(app.LeaderComponent): self.expect_redirects = expect_redirects self.last_updated_version = last_updated_version or 0 - def is_enabled(self): + def is_enabled(self) -> bool: """Return whether the Apache configuration is enabled.""" return action_utils.webserver_is_enabled(self.web_name, kind=self.kind) - def enable(self): + def enable(self) -> None: """Enable the Apache configuration.""" privileged.enable(self.web_name, self.kind) - def disable(self): + def disable(self) -> None: """Disable the Apache configuration.""" privileged.disable(self.web_name, self.kind) @@ -63,7 +67,6 @@ class Webserver(app.LeaderComponent): """Check if the web path is accessible by clients. See :py:meth:`plinth.app.Component.diagnose`. - """ results = [] for url in self.urls: @@ -79,7 +82,7 @@ class Webserver(app.LeaderComponent): return results - def setup(self, old_version): + def setup(self, old_version: int): """Restart/reload web server if configuration files changed.""" if not old_version: # App is being freshly setup. After setup, app will be enabled @@ -102,10 +105,115 @@ class Webserver(app.LeaderComponent): service_privileged.reload('apache2') +class WebserverRoot(app.FollowerComponent): + """Component to enable/disable Apache configuration for domain root. + + Each domain has a unique virtual host configuration in Apache. This file + includes an option configuration file that can dropped in by FreedomBox. If + an app wants to be hosted on a dedicated domain, it can provide a + configuration file that is meant to be in the section. Using + this component, the include file fragment for a selected domain can be + linked to app's configuration file. Then, for the selected domain, the + app's configuration becomes the domain's root configuration. + + This components uses key/value store to remember the selected domain. When + the domain changes, the change must be notified using domain_set(). + """ + + def __init__(self, component_id: str, web_name: str, + expect_redirects: bool = False, + last_updated_version: int | None = None): + """Initialize the web server component for domain root. + + component_id should be a unique ID across all components of an app and + across all components. + + web_name is the primary part of the configuration file path which must + be enabled/disabled by this component. The file's path should be + /etc/apache2/includes/.conf. + + expect_redirects is a boolean that allows redirects when trying to + access the domain URL during diagnosis of the component. + + last_updated_version is the app version in which the web server + configuration/site/module file was updated. Using this, web server will + be automatically reloaded or restarted as necessary during app upgrade. + """ + super().__init__(component_id) + + self.web_name = web_name + self.expect_redirects = expect_redirects + self.last_updated_version = last_updated_version or 0 + + def enable(self) -> None: + """Link the Apache site root configuration to app configuration.""" + domain = self.domain_get() + if domain: + privileged.link_root(domain, self.web_name) + + def disable(self) -> None: + """Unlink the Apache site root configuration from app configuration.""" + domain = self.domain_get() + if domain: + privileged.unlink_root(domain) + + def _key_get(self) -> str: + """Return the key used to store the domain in kvstore.""" + return f'{self.component_id}_domain' + + def domain_get(self) -> str | None: + """Return the currently configured domain name.""" + return kvstore.get_default(self._key_get(), None) + + def domain_set(self, domain: str | None): + """Set the domain to use with the app.""" + self.disable() + kvstore.set(self._key_get(), domain) + if self.app.is_enabled(): + self.enable() + + def diagnose(self) -> list[DiagnosticCheck]: + """Check if the site root path is accessible by clients. + + See :py:meth:`plinth.app.Component.diagnose`. + """ + results = [] + domain = self.domain_get() + if domain: + results.append( + diagnose_url(f'https://{domain}', check_certificate=False, + component_id=self.component_id)) + + return results + + def setup(self, old_version: int): + """Restart/reload web server if configuration files changed.""" + if not old_version: + # App is being freshly setup. After setup, app will be enabled + # which will result in reload/restart of web server. + return + + if old_version >= self.last_updated_version: + # Already using the latest configuration. Web server reload/restart + # is not necessary. + return + + if not self.app.is_enabled(): + # App is currently disabled, web server will reloaded/restarted + # when the app is enabled. + return + + service_privileged.reload('apache2') + + def uninstall(self): + """Remove the domain configured.""" + kvstore.delete(self._key_get(), ignore_missing=True) + + class Uwsgi(app.LeaderComponent): """Component to enable/disable uWSGI configuration.""" - def __init__(self, component_id, uwsgi_name): + def __init__(self, component_id: str, uwsgi_name: str): """Initialize the uWSGI component. component_id should be a unique ID across all components of an app and @@ -119,20 +227,20 @@ class Uwsgi(app.LeaderComponent): self.uwsgi_name = uwsgi_name - def is_enabled(self): + def is_enabled(self) -> bool: """Return whether the uWSGI configuration is enabled.""" return action_utils.uwsgi_is_enabled(self.uwsgi_name) \ and action_utils.service_is_enabled('uwsgi') - def enable(self): + def enable(self) -> None: """Enable the uWSGI configuration.""" privileged.uwsgi_enable(self.uwsgi_name) - def disable(self): + def disable(self) -> None: """Disable the uWSGI configuration.""" privileged.uwsgi_disable(self.uwsgi_name) - def is_running(self): + def is_running(self) -> bool: """Return whether the uWSGI daemon is running with configuration.""" return action_utils.uwsgi_is_enabled(self.uwsgi_name) \ and action_utils.service_is_running('uwsgi') diff --git a/plinth/modules/apache/privileged.py b/plinth/modules/apache/privileged.py index 2612c1ee8..1093ae989 100644 --- a/plinth/modules/apache/privileged.py +++ b/plinth/modules/apache/privileged.py @@ -3,6 +3,7 @@ import glob import os +import pathlib import re import subprocess @@ -173,6 +174,36 @@ def _assert_kind(kind: str): raise ValueError('Invalid value for parameter kind') +@privileged +def link_root(domain: str, name: str): + """Link the Apache site root configuration to app configuration.""" + if '/' in domain or '/' in name: + raise ValueError('Invalid domain or name') + + target_config = f'{name}.conf' + + include_root = pathlib.Path('/etc/apache2/includes/') + config = include_root / f'{domain}-include-freedombox.conf' + config.unlink(missing_ok=True) + config.symlink_to(target_config) + action_utils.service_reload('apache2') + + +@privileged +def unlink_root(domain: str): + """Unlink the Apache site root configuration from app configuration.""" + if '/' in domain: + raise ValueError('Invalid domain') + + include_root = pathlib.Path('/etc/apache2/includes/') + config = include_root / f'{domain}-include-freedombox.conf' + if not config.is_symlink(): + return # Does not exist or not a symlink + + config.unlink() + action_utils.service_reload('apache2') + + @privileged def uwsgi_enable(name: str): """Enable uWSGI configuration and reload.""" diff --git a/plinth/modules/apache/tests/test_components.py b/plinth/modules/apache/tests/test_components.py index 9a7a2cd83..e0fd862e0 100644 --- a/plinth/modules/apache/tests/test_components.py +++ b/plinth/modules/apache/tests/test_components.py @@ -4,14 +4,14 @@ Test module for webserver components. """ import subprocess -from unittest.mock import call, patch +from unittest.mock import Mock, PropertyMock, call, patch import pytest -from plinth import app +from plinth import app, kvstore from plinth.diagnostic_check import DiagnosticCheck, Result -from plinth.modules.apache.components import (Uwsgi, Webserver, check_url, - diagnose_url, +from plinth.modules.apache.components import (Uwsgi, Webserver, WebserverRoot, + check_url, diagnose_url, diagnose_url_on_all) @@ -164,6 +164,168 @@ def test_webserver_setup(service_reload, service_restart): service_reload.assert_not_called() +def test_webserver_root_init(): + """Test that webserver root component can be initialized.""" + with pytest.raises(ValueError): + WebserverRoot(None, None) + + webserver = WebserverRoot('test-webserverroot', 'test-config', + expect_redirects=True, last_updated_version=10) + assert webserver.component_id == 'test-webserverroot' + assert webserver.web_name == 'test-config' + assert webserver.expect_redirects + assert webserver.last_updated_version == 10 + + webserver = WebserverRoot('test-webserverroot', None) + assert not webserver.expect_redirects + assert webserver.last_updated_version == 0 + + +@patch('plinth.modules.apache.privileged.link_root') +def test_webserver_root_enable(link_root): + """Test that enabling webserver root works.""" + webserver = WebserverRoot('test-webserver', 'test-config') + + with patch('plinth.modules.apache.components.WebserverRoot.domain_get' + ) as get: + get.return_value = None + webserver.enable() + link_root.assert_not_called() + + get.return_value = 'x-domain' + webserver.enable() + link_root.assert_has_calls([call('x-domain', 'test-config')]) + + +@patch('plinth.modules.apache.privileged.unlink_root') +def test_webserver_root_disable(unlink_root): + """Test that disabling webserver root works.""" + webserver = WebserverRoot('test-webserver', 'test-config') + + with patch('plinth.modules.apache.components.WebserverRoot.domain_get' + ) as get: + get.return_value = None + webserver.disable() + unlink_root.assert_not_called() + + get.return_value = 'x-domain' + webserver.disable() + unlink_root.assert_has_calls([call('x-domain')]) + + +@pytest.mark.django_db +def test_webserver_root_domain_get(): + """Test retrieving webserver root's domain.""" + webserver = WebserverRoot('test-webserver', 'test-config') + + assert webserver.domain_get() is None + kvstore.set('test-webserver_domain', 'test-domain') + assert webserver.domain_get() == 'test-domain' + + +@pytest.mark.django_db +@patch('plinth.modules.apache.privileged.unlink_root') +@patch('plinth.modules.apache.privileged.link_root') +@patch('plinth.app.Component.app', new_callable=PropertyMock) +def test_webserver_root_domain_set(component_app, link_root, unlink_root): + """Test setting webserver root's domain.""" + webserver = WebserverRoot('test-webserver', 'test-config') + assert webserver.domain_get() is None + + app = Mock() + component_app.return_value = app + app.is_enabled.return_value = True + webserver.domain_set('test-domain') + assert unlink_root.mock_calls == [] + assert webserver.domain_get() == 'test-domain' + assert link_root.mock_calls == [call('test-domain', 'test-config')] + link_root.reset_mock() + + app.is_enabled.return_value = False + assert not webserver.app.is_enabled() + webserver.domain_set('test-domain2') + assert unlink_root.mock_calls == [call('test-domain')] + assert webserver.domain_get() == 'test-domain2' + assert link_root.mock_calls == [] + + webserver.domain_set(None) + assert webserver.domain_get() is None + + +@pytest.mark.django_db +@patch('plinth.modules.apache.components.WebserverRoot.disable') +@patch('plinth.modules.apache.components.WebserverRoot.enable') +@patch('plinth.modules.apache.components.diagnose_url') +@patch('plinth.app.Component.app', new_callable=PropertyMock) +def test_webserver_root_diagnose(component_app, diagnose_url, enable, disable): + """Test running diagnostics on webserver root component.""" + webserver = WebserverRoot('test-webserver', 'test-config') + assert webserver.diagnose() == [] + + webserver.domain_set('test-domain') + result = DiagnosticCheck('test-all-id', 'test-result', 'success', {}, + 'message') + diagnose_url.return_value = result + assert webserver.diagnose() == [result] + + +@patch('plinth.privileged.service.reload') +def test_webserver_root_setup(service_reload): + """Test that component reloads web server during app upgrades.""" + + class AppTest(app.App): + app_id = 'testapp' + enabled = False + + def is_enabled(self): + return self.enabled + + app1 = AppTest() + + # Don't fail when last_updated_version is not provided. + webserver1 = WebserverRoot('test-webserverroot1', 'test-config') + assert webserver1.last_updated_version == 0 + webserver1.setup(old_version=10) + service_reload.assert_not_called() + + webserver1 = WebserverRoot('test-webserverroot1', 'test-config', + last_updated_version=5) + for version in (0, 5, 6): + webserver1.setup(old_version=version) + service_reload.assert_not_called() + + app1.enabled = False + webserver2 = WebserverRoot('test-webserver2', 'test-config', + last_updated_version=5) + app1.add(webserver2) + webserver2.setup(old_version=3) + service_reload.assert_not_called() + + app1.enabled = True + webserver3 = WebserverRoot('test-webserver3', 'test-config', + last_updated_version=5) + app1.add(webserver3) + webserver3.setup(old_version=3) + service_reload.assert_has_calls([call('apache2')]) + service_reload.reset_mock() + + +@pytest.mark.django_db +@patch('plinth.modules.apache.components.WebserverRoot.disable') +@patch('plinth.modules.apache.components.WebserverRoot.enable') +@patch('plinth.app.Component.app', new_callable=PropertyMock) +def test_webserver_root_uninstall(component_app, enable, disable): + """Test that component removes the DB key during uninstall.""" + webserver = WebserverRoot('test-webserver', 'test-config') + webserver.uninstall() + assert kvstore.get_default('test-webserver_domain', 'x-value') == 'x-value' + + webserver.domain_set('test-domain') + assert kvstore.get('test-webserver_domain') == 'test-domain' + webserver.uninstall() + assert kvstore.get_default('test-webserver_domain', 'x-value') == 'x-value' + + def test_uwsgi_init(): """Test that uWSGI component can be initialized.""" with pytest.raises(ValueError):