*: Use privileged decorator for service actions

Tests:

- DONE: Unit tests work
- DONE: Transmission
  - DONE: Enabling/disabling an app with a daemon works: transmission
  - DONE: Showing the status of whether the app is enabled with daemon
    is-enabled works.
  - DONE: A message is shown if app is enabled and service is not running
  - DONE: Service is stopped and re-started during backup
  - DONE: Adding user to share group during initial setup restarts the service
- Not tested: Enabling/disabling a service with alias works (no such apps)
- DONE: Restarting/try-restarting a service works
- DONE: Masking/unmasking works
  - DONE: rsyslog is masked after initial setup
  - DONE: systemd-journald is try-restarted during initial setup
- DONE: Avahi, email, security initial setup works
  - DONE: Fail2ban is unmasked and enabled
- DONE: Enabling/disabling fail2ban is security app works
- DONE: Enabling/disabling password authentication in SSH works
- ?? Let's encrypt
  - Services are try-restarted during certificate setup, obtain, renew
- Not tested: upgrade pagekite from version 1

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2022-09-03 08:25:57 -07:00 committed by James Valleroy
parent 1dcbfce713
commit 222563a482
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
16 changed files with 229 additions and 181 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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