diff --git a/actions/service b/actions/service deleted file mode 100755 index 2da7c4d3b..000000000 --- a/actions/service +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Wrapper to list and handle system services -""" - -import argparse -import os - -from plinth import action_utils -from plinth import app as app_module -from plinth import cfg, module_loader -from plinth.daemon import Daemon, RelatedDaemon - -cfg.read() -module_config_path = os.path.join(cfg.config_dir, 'modules-enabled') - - -def add_service_action(subparsers, action, help): - parser = subparsers.add_parser(action, help=help) - parser.add_argument('service', help='name of the service') - - -def parse_arguments(): - """Return parsed command line arguments as dictionary.""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') - - add_service_action(subparsers, 'start', 'start a service') - add_service_action(subparsers, 'stop', 'stop a service') - add_service_action(subparsers, 'enable', 'enable a service') - add_service_action(subparsers, 'disable', 'disable a service') - add_service_action(subparsers, 'restart', 'restart a service') - add_service_action(subparsers, 'try-restart', - 'restart a service if running') - add_service_action(subparsers, 'reload', 'reload a service') - add_service_action(subparsers, 'is-running', 'status of a service') - add_service_action(subparsers, 'is-enabled', 'status a service') - add_service_action(subparsers, 'mask', 'unmask a service') - add_service_action(subparsers, 'unmask', 'unmask a service') - - subparsers.required = True - return parser.parse_args() - - -def subcommand_start(arguments): - action_utils.service_start(arguments.service) - - -def subcommand_stop(arguments): - action_utils.service_stop(arguments.service) - - -def subcommand_enable(arguments): - action_utils.service_enable(arguments.service) - - -def subcommand_disable(arguments): - action_utils.service_disable(arguments.service) - - -def subcommand_restart(arguments): - action_utils.service_restart(arguments.service) - - -def subcommand_try_restart(arguments): - action_utils.service_try_restart(arguments.service) - - -def subcommand_reload(arguments): - action_utils.service_reload(arguments.service) - - -def subcommand_mask(arguments): - action_utils.service_mask(arguments.service) - - -def subcommand_unmask(arguments): - action_utils.service_unmask(arguments.service) - - -def subcommand_is_enabled(arguments): - print(action_utils.service_is_enabled(arguments.service)) - - -def subcommand_is_running(arguments): - print(action_utils.service_is_running(arguments.service)) - - -def _get_managed_services(): - """Get a set of all services managed by FreedomBox.""" - services = set() - module_loader.load_modules() - app_module.apps_init() - for app in app_module.App.list(): - components = app.get_components_of_type(Daemon) - for component in components: - services.add(component.unit) - if component.alias: - services.add(component.alias) - - components = app.get_components_of_type(RelatedDaemon) - for component in components: - services.add(component.unit) - - return services - - -def _assert_service_is_managed_by_plinth(service_name): - managed_services = _get_managed_services() - if service_name not in managed_services: - msg = ("The service '%s' is not managed by FreedomBox. Access is only " - "permitted for services listed in the 'managed_services' " - "variable of any FreedomBox app.") % service_name - raise ValueError(msg) - - -def main(): - """Parse arguments and perform all duties.""" - arguments = parse_arguments() - - subcommand = arguments.subcommand.replace('-', '_') - subcommand_method = globals()['subcommand_' + subcommand] - if hasattr(arguments, 'service'): - _assert_service_is_managed_by_plinth(arguments.service) - subcommand_method(arguments) - - -if __name__ == '__main__': - main() diff --git a/plinth/daemon.py b/plinth/daemon.py index 5d1935e83..de1d8d0a3 100644 --- a/plinth/daemon.py +++ b/plinth/daemon.py @@ -1,7 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -Component for managing a background daemon or any systemd unit. -""" +"""Component for managing a background daemon or any systemd unit.""" import socket import subprocess @@ -11,7 +9,7 @@ from django.utils.text import format_lazy from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy -from plinth import action_utils, actions, app +from plinth import action_utils, app class Daemon(app.LeaderComponent): @@ -70,15 +68,17 @@ class Daemon(app.LeaderComponent): def enable(self): """Run operations to enable the daemon/unit.""" - actions.superuser_run('service', ['enable', self.unit]) + from plinth.privileged import service as service_privileged + service_privileged.enable(self.unit) if self.alias: - actions.superuser_run('service', ['enable', self.alias]) + service_privileged.enable(self.alias) def disable(self): """Run operations to disable the daemon/unit.""" - actions.superuser_run('service', ['disable', self.unit]) + from plinth.privileged import service as service_privileged + service_privileged.disable(self.unit) if self.alias: - actions.superuser_run('service', ['disable', self.alias]) + service_privileged.disable(self.alias) def is_running(self): """Return whether the daemon/unit is running.""" diff --git a/plinth/modules/avahi/__init__.py b/plinth/modules/avahi/__init__.py index f06bbf271..420b4153b 100644 --- a/plinth/modules/avahi/__init__.py +++ b/plinth/modules/avahi/__init__.py @@ -1,11 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -FreedomBox app for service discovery. -""" +"""FreedomBox app for service discovery.""" from django.utils.translation import gettext_lazy as _ -from plinth import actions from plinth import app as app_module from plinth import cfg, menu from plinth.daemon import Daemon @@ -14,6 +11,7 @@ from plinth.modules.config import get_hostname from plinth.modules.firewall.components import Firewall from plinth.modules.names.components import DomainType from plinth.package import Packages +from plinth.privileged import service as service_privileged from plinth.signals import domain_added, domain_removed, post_hostname_change from plinth.utils import format_lazy @@ -90,7 +88,7 @@ class AvahiApp(app_module.App): # Reload avahi-daemon now that first-run does not reboot. After # performing FreedomBox Service (Plinth) package installation, new # Avahi files will be available and require restart. - actions.superuser_run('service', ['reload', 'avahi-daemon']) + service_privileged.reload('avahi-daemon') self.enable() diff --git a/plinth/modules/backups/api.py b/plinth/modules/backups/api.py index d2be5ca41..6763b396e 100644 --- a/plinth/modules/backups/api.py +++ b/plinth/modules/backups/api.py @@ -12,10 +12,11 @@ TODO: import logging -from plinth import action_utils, actions +from plinth import action_utils from plinth import app as app_module from plinth import setup from plinth.modules.apache import privileged as apache_privileged +from plinth.privileged import service as service_privileged from .components import BackupRestore @@ -318,12 +319,12 @@ class SystemServiceHandler(ServiceHandler): """Stop the service.""" self.was_running = action_utils.service_is_running(self.service) if self.was_running: - actions.superuser_run('service', ['stop', self.service]) + service_privileged.stop(self.service) def restart(self): """Restart the service if it was earlier running.""" if self.was_running: - actions.superuser_run('service', ['start', self.service]) + service_privileged.start(self.service) class ApacheServiceHandler(ServiceHandler): diff --git a/plinth/modules/config/__init__.py b/plinth/modules/config/__init__.py index 93cedeb84..9910c6798 100644 --- a/plinth/modules/config/__init__.py +++ b/plinth/modules/config/__init__.py @@ -13,6 +13,7 @@ from plinth.modules.apache import (get_users_with_website, user_of_uws_url, uws_url_of_user) from plinth.modules.names.components import DomainType from plinth.package import Packages +from plinth.privileged import service as service_privileged from plinth.signals import domain_added from . import privileged @@ -82,14 +83,14 @@ class ConfigApp(app_module.App): # systemd-journald is socket activated, it may not be running and it # does not support reload. - actions.superuser_run('service', ['try-restart', 'systemd-journald']) + service_privileged.try_restart('systemd-journald') # rsyslog when enabled, is activated by syslog.socket (shipped by # systemd). See: # https://www.freedesktop.org/wiki/Software/systemd/syslog/ . - actions.superuser_run('service', ['disable', 'rsyslog']) + service_privileged.disable('rsyslog') # Ensure that rsyslog is not started by something else as it is # installed by default on Debian systems. - actions.superuser_run('service', ['mask', 'rsyslog']) + service_privileged.mask('rsyslog') def get_domainname(): diff --git a/plinth/modules/email/__init__.py b/plinth/modules/email/__init__.py index 76cb5fa3b..03e2564dd 100644 --- a/plinth/modules/email/__init__.py +++ b/plinth/modules/email/__init__.py @@ -7,7 +7,7 @@ from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ import plinth.app -from plinth import actions, cfg, frontpage, menu +from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.backups.components import BackupRestore @@ -15,6 +15,7 @@ from plinth.modules.config import get_domainname from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.package import Packages, uninstall +from plinth.privileged import service as service_privileged from plinth.signals import domain_added, domain_removed from plinth.utils import format_lazy @@ -189,9 +190,9 @@ class EmailApp(plinth.app.App): privileged.setup_spam() # Restart daemons - actions.superuser_run('service', ['try-restart', 'postfix']) - actions.superuser_run('service', ['try-restart', 'dovecot']) - actions.superuser_run('service', ['try-restart', 'rspamd']) + service_privileged.try_restart('postfix') + service_privileged.try_restart('dovecot') + service_privileged.try_restart('rspamd') # Expose to public internet if old_version == 0: diff --git a/plinth/modules/letsencrypt/components.py b/plinth/modules/letsencrypt/components.py index 5fa760598..f951277fa 100644 --- a/plinth/modules/letsencrypt/components.py +++ b/plinth/modules/letsencrypt/components.py @@ -5,8 +5,9 @@ import logging import pathlib import threading -from plinth import actions, app +from plinth import app from plinth.modules.names.components import DomainName +from plinth.privileged import service as service_privileged from . import privileged @@ -168,7 +169,7 @@ class LetsEncrypt(app.FollowerComponent): self._copy_self_signed_certificates([domain]) for daemon in self.daemons: - actions.superuser_run('service', ['try-restart', daemon]) + service_privileged.try_restart(daemon) def get_status(self): """Return the status of certificates for all interested domains. @@ -213,7 +214,7 @@ class LetsEncrypt(app.FollowerComponent): self._copy_letsencrypt_certificates(interested_domains, lineage) for daemon in self.daemons: - actions.superuser_run('service', ['try-restart', daemon]) + service_privileged.try_restart(daemon) def on_certificate_renewed(self, domains, lineage): """Handle event when a certificate is renewed. @@ -247,7 +248,7 @@ class LetsEncrypt(app.FollowerComponent): self._copy_self_signed_certificates(interested_domains) for daemon in self.daemons: - actions.superuser_run('service', ['try-restart', daemon]) + service_privileged.try_restart(daemon) def on_certificate_deleted(self, domains, lineage): """Handle event when a certificate is deleted. diff --git a/plinth/modules/pagekite/__init__.py b/plinth/modules/pagekite/__init__.py index e85189b70..c9df15ec2 100644 --- a/plinth/modules/pagekite/__init__.py +++ b/plinth/modules/pagekite/__init__.py @@ -9,6 +9,7 @@ from plinth.daemon import Daemon from plinth.modules.backups.components import BackupRestore from plinth.modules.names.components import DomainType from plinth.package import Packages +from plinth.privileged import service as service_privileged from plinth.utils import format_lazy from . import manifest, utils @@ -103,5 +104,4 @@ class PagekiteApp(app_module.App): self.enable() if old_version == 1: - actions.superuser_run('service', - ['try-restart', PagekiteApp.DAEMON]) + service_privileged.try_restart(PagekiteApp.DAEMON) diff --git a/plinth/modules/security/__init__.py b/plinth/modules/security/__init__.py index d43a0bf00..e319dd879 100644 --- a/plinth/modules/security/__init__.py +++ b/plinth/modules/security/__init__.py @@ -7,12 +7,12 @@ from collections import defaultdict from django.utils.translation import gettext_lazy as _ -from plinth import actions from plinth import app as app_module from plinth import menu from plinth.daemon import Daemon, RelatedDaemon from plinth.modules.backups.components import BackupRestore from plinth.package import Packages +from plinth.privileged import service as service_privileged from . import manifest, privileged @@ -55,7 +55,7 @@ class SecurityApp(app_module.App): if not old_version: enable_fail2ban() - actions.superuser_run('service', ['reload', 'fail2ban']) + service_privileged.reload('fail2ban') # Migrate to new config file. enabled = privileged.get_restricted_access_enabled() @@ -66,8 +66,8 @@ class SecurityApp(app_module.App): def enable_fail2ban(): """Unmask, enable and run the fail2ban service.""" - actions.superuser_run('service', ['unmask', 'fail2ban']) - actions.superuser_run('service', ['enable', 'fail2ban']) + service_privileged.unmask('fail2ban') + service_privileged.enable('fail2ban') def set_restricted_access(enabled): diff --git a/plinth/modules/security/views.py b/plinth/modules/security/views.py index 8d35c60f1..6a24fc87d 100644 --- a/plinth/modules/security/views.py +++ b/plinth/modules/security/views.py @@ -5,9 +5,10 @@ from django.contrib import messages from django.template.response import TemplateResponse from django.utils.translation import gettext as _ -from plinth import action_utils, actions +from plinth import action_utils from plinth.modules import security from plinth.modules.upgrades import is_backports_requested +from plinth.privileged import service as service_privileged from plinth.views import AppView from . import privileged @@ -63,9 +64,9 @@ def _apply_changes(request, old_status, new_status): if old_status['fail2ban_enabled'] != new_status['fail2ban_enabled']: if new_status['fail2ban_enabled']: - actions.superuser_run('service', ['enable', 'fail2ban']) + service_privileged.enable('fail2ban') else: - actions.superuser_run('service', ['disable', 'fail2ban']) + service_privileged.disable('fail2ban') def report(request): diff --git a/plinth/modules/ssh/views.py b/plinth/modules/ssh/views.py index d35c0b0ec..b1f84a231 100644 --- a/plinth/modules/ssh/views.py +++ b/plinth/modules/ssh/views.py @@ -4,8 +4,8 @@ from django.contrib import messages from django.utils.translation import gettext_lazy as _ -from plinth import actions from plinth.modules import ssh +from plinth.privileged import service as service_privileged from plinth.views import AppView from . import privileged @@ -48,7 +48,7 @@ class SshAppView(AppView): if passwd_auth_changed: privileged.set_password_authentication( not new_config['password_auth_disabled']) - actions.superuser_run('service', ['reload', 'ssh']) + service_privileged.reload('ssh') messages.success(self.request, _('Configuration updated')) return super().form_valid(form) diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index 7176ac9a8..5e7f68e89 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -7,11 +7,11 @@ import subprocess from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ -from plinth import actions from plinth import app as app_module from plinth import cfg, menu from plinth.daemon import Daemon from plinth.package import Packages +from plinth.privileged import service as service_privileged from . import privileged from .components import UsersAndGroups @@ -131,4 +131,4 @@ def add_user_to_share_group(username, service=None): if username not in group_members: privileged.add_user_to_group(username, 'freedombox-share') if service: - actions.superuser_run('service', ['try-restart', service]) + service_privileged.try_restart(service) diff --git a/plinth/modules/wordpress/__init__.py b/plinth/modules/wordpress/__init__.py index c07872e89..7b933a79c 100644 --- a/plinth/modules/wordpress/__init__.py +++ b/plinth/modules/wordpress/__init__.py @@ -10,6 +10,7 @@ from plinth.modules.apache.components import Webserver from plinth.modules.backups.components import BackupRestore from plinth.modules.firewall.components import Firewall from plinth.package import Packages +from plinth.privileged import service as service_privileged from plinth.utils import format_lazy from . import manifest, privileged @@ -106,7 +107,7 @@ class WordPressApp(app_module.App): self.enable() elif old_version < 3: # Apply changes to Apache configuration from v2 to v3. - actions.superuser_run('service', ['reload', 'apache2']) + service_privileged.reload('apache2') class WordPressBackupRestore(BackupRestore): diff --git a/plinth/privileged/__init__.py b/plinth/privileged/__init__.py new file mode 100644 index 000000000..82d80ca2b --- /dev/null +++ b/plinth/privileged/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Package holding all the privileged actions outside of apps.""" + +from .service import (disable, enable, is_enabled, is_running, mask, reload, + restart, start, stop, try_restart, unmask) + +__all__ = [ + 'disable', 'enable', 'is_enabled', 'is_running', 'mask', 'reload', + 'restart', 'start', 'stop', 'try_restart', 'unmask' +] diff --git a/plinth/privileged/service.py b/plinth/privileged/service.py new file mode 100644 index 000000000..81f04238b --- /dev/null +++ b/plinth/privileged/service.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""List and handle system services.""" + +import os + +from plinth import action_utils +from plinth import app as app_module +from plinth import cfg, module_loader +from plinth.actions import privileged +from plinth.daemon import Daemon, RelatedDaemon + +cfg.read() +module_config_path = os.path.join(cfg.config_dir, 'modules-enabled') + + +@privileged +def start(service: str): + """Start a service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_start(service) + + +@privileged +def stop(service: str): + """Stop a running service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_stop(service) + + +@privileged +def enable(service: str): + """Enable a service so that it start on system boot.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_enable(service) + + +@privileged +def disable(service: str): + """Disable a service so that it does not start on system boot.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_disable(service) + + +@privileged +def restart(service: str): + """Restart a service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_restart(service) + + +@privileged +def try_restart(service: str): + """Restart a service if it is running.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_try_restart(service) + + +@privileged +def reload(service: str): + """Reload a service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_reload(service) + + +@privileged +def mask(service: str): + """Mask a service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_mask(service) + + +@privileged +def unmask(service: str): + """Unmask a service.""" + _assert_service_is_managed_by_plinth(service) + action_utils.service_unmask(service) + + +@privileged +def is_enabled(service: str) -> bool: + """Return whether a service is enabled.""" + _assert_service_is_managed_by_plinth(service) + return action_utils.service_is_enabled(service) + + +@privileged +def is_running(service: str) -> bool: + """Return whether a service is running.""" + _assert_service_is_managed_by_plinth(service) + return action_utils.service_is_running(service) + + +def _get_managed_services(): + """Get a set of all services managed by FreedomBox.""" + services = set() + module_loader.load_modules() + app_module.apps_init() + for app in app_module.App.list(): + components = app.get_components_of_type(Daemon) + for component in components: + services.add(component.unit) + if component.alias: + services.add(component.alias) + + components = app.get_components_of_type(RelatedDaemon) + for component in components: + services.add(component.unit) + + return services + + +def _assert_service_is_managed_by_plinth(service_name): + managed_services = _get_managed_services() + if service_name not in managed_services: + msg = ("The service '%s' is not managed by FreedomBox. Access is only " + "permitted for services listed in the 'managed_services' " + "variable of any FreedomBox app.") % service_name + raise ValueError(msg) diff --git a/plinth/tests/test_daemon.py b/plinth/tests/test_daemon.py index c28d73db2..8b68f5796 100644 --- a/plinth/tests/test_daemon.py +++ b/plinth/tests/test_daemon.py @@ -4,14 +4,23 @@ Test module for component managing system daemons and other systemd units. """ import socket +import subprocess from unittest.mock import Mock, call, patch import pytest -from plinth.app import App, FollowerComponent +from plinth.app import App, FollowerComponent, Info from plinth.daemon import (Daemon, RelatedDaemon, app_is_running, diagnose_netcat, diagnose_port_listening) +privileged_modules_to_mock = ['plinth.privileged.service'] + + +class AppTest(App): + """Test application that contains a daemon.""" + + app_id = 'test-app' + @pytest.fixture(name='daemon') def fixture_daemon(): @@ -19,6 +28,17 @@ def fixture_daemon(): return Daemon('test-daemon', 'test-unit') +@pytest.fixture(name='app_list') +def fixture_app_list(daemon): + """A list of apps on which tests are to be run.""" + app1 = AppTest() + app1.add(Info('test-app', 1)) + app1.add(daemon) + with patch('plinth.app.App.list') as app_list: + app_list.return_value = [app1] + yield app_list + + def test_initialization(): """Test that component is initialized properly.""" with pytest.raises(ValueError): @@ -56,25 +76,51 @@ def test_is_enabled(service_is_enabled, daemon): service_is_enabled.assert_has_calls([call('test-unit', strict_check=True)]) -@patch('plinth.actions.superuser_run') -def test_enable(superuser_run, daemon): +@patch('subprocess.run') +@patch('subprocess.call') +def test_enable(subprocess_call, subprocess_run, app_list, mock_privileged, + daemon): """Test that enabling the daemon works.""" daemon.enable() - superuser_run.assert_has_calls([call('service', ['enable', 'test-unit'])]) + subprocess_call.assert_has_calls( + [call(['systemctl', 'enable', 'test-unit'])]) + subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit'], + stdout=subprocess.DEVNULL, check=False) + subprocess_call.reset_mock() daemon.alias = 'test-unit-2' daemon.enable() - superuser_run.assert_has_calls([ - call('service', ['enable', 'test-unit']), - call('service', ['enable', 'test-unit-2']) + subprocess_call.assert_has_calls([ + call(['systemctl', 'enable', 'test-unit']), + call(['systemctl', 'enable', 'test-unit-2']) ]) + subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit'], + stdout=subprocess.DEVNULL, check=False) + subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit-2'], + stdout=subprocess.DEVNULL, check=False) -@patch('plinth.actions.superuser_run') -def test_disable(superuser_run, daemon): +@patch('subprocess.run') +@patch('subprocess.call') +def test_disable(subprocess_call, subprocess_run, mock_privileged, daemon): """Test that disabling the daemon works.""" daemon.disable() - superuser_run.assert_has_calls([call('service', ['disable', 'test-unit'])]) + subprocess_call.assert_has_calls( + [call(['systemctl', 'disable', 'test-unit'])]) + subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit'], + stdout=subprocess.DEVNULL, check=False) + + subprocess_call.reset_mock() + daemon.alias = 'test-unit-2' + daemon.disable() + subprocess_call.assert_has_calls([ + call(['systemctl', 'disable', 'test-unit']), + call(['systemctl', 'disable', 'test-unit-2']) + ]) + subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit'], + stdout=subprocess.DEVNULL, check=False) + subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit-2'], + stdout=subprocess.DEVNULL, check=False) @patch('plinth.action_utils.service_is_running')