FreedomBox/plinth/config.py
Sunil Mohan Adapa 465e452daf
diagnostics: Refactor check IDs, tests and background checks
- Ensure that each diagnostic test category can be identified by easy prefix
matching on the test ID.

- Give a different unique IDs each different kind of test. More specific tests
of a type get a different kind of ID.

- Make comparison of diagnostic test results in test cases more comprehensive.

- Simplify code that shows the number if issues identified.

- In many languages, there is complex logic to write plural forms. Plurals
can't be handled by assuming singular = 1 item and plural is > 1. Translation of
messages in Notification does not support plurals properly. Avoid this for now
by using sometimes incorrect plural form.

- For i18n we should avoid joining phrases/words. Words don't always maintain
order after translation.

- Notify about the total number of issues in diagnostics and not just the most
severe category. This is likely to draw more attention and avoid i18n
complexity.

- Dismiss the diagnostic notification if the latest run succeeded completely.

Tests:

- Unit tests pass.

- Diagnostics for following apps works: networks (drop-in config),
apache (daemon, listen address, internal firewall, external firewall),
tor (netcat), torproxy (internal only firewall, torproxy url, torproxy using
tor), privoxy (privoxy url, package available, package latest),

- Untested: Is release file available method in upgrades app.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2023-10-07 04:52:22 +09:00

138 lines
5.9 KiB
Python

"""Components for managing configuration files."""
import logging
import pathlib
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from plinth.privileged import config as privileged
from . import app as app_module
logger = logging.getLogger(__name__)
class DropinConfigs(app_module.FollowerComponent):
"""Component to manage config files dropped into /etc.
When configuring a daemon, it is often simpler to ship a configuration file
into the daemon's configuration directory. However, if the user modifies
this configuration file and freedombox ships a new version of this
configuration file, then a conflict arises between user's changes and
changes in the new version of configuration file shipped by freedombox.
This leads to freedombox package getting marked by unattended-upgrades as
not automatically upgradable. Dpkg's solution of resolving the conflicts is
to present the option to the user which is also not acceptable.
Further, if a package is purged from the system, sometimes the
configuration directories are fully removed by deb's scripts. This removes
files installed by freedombox package. Dpkg treats these files as if user
has explictly removed them and may lead to a configuration conflict
described above.
The approach freedombox takes to address these issues is using this
component. Files are shipped into /usr/share/freedombox/etc/ instead of
/etc/ (keeping the subpath unchanged). Then when an app is enabled, a
symlink or copy is created from the /usr/share/freedombox/etc/ into /etc/.
This way, user's understand the configuration file is not meant to be
edited. Even if they do, next upgrade of freedombox package will silently
overwrite those changes without causing merge conflicts. Also when purging
a package removes entire configuration directory, only symlinks/copies are
lost. They will recreated when the app is reinstalled/enabled.
"""
ROOT = '/' # To make writing tests easier
DROPIN_CONFIG_ROOT = '/usr/share/freedombox/'
def __init__(self, component_id, etc_paths=None, copy_only=False):
"""Initialize the drop-in configuration component.
component_id should be a unique ID across all components of an app and
across all components.
etc_paths is a list of all drop-in configuration files as absolute
paths in /etc/ which need to managed by this component. For each of the
paths, it is expected that the actual configuration file exists in
/usr/share/freedombox/etc/. A link to the file or copy of the file is
created in /etc/ when app is enabled and the link or file is removed
when app is disabled. For example, if etc_paths contains
/etc/apache/conf-enabled/myapp.conf then
/usr/share/freedombox/etc/apache/conf-enabled/myapp.conf must be
shipped and former path will be link to or be a copy of the latter when
app is enabled.
"""
super().__init__(component_id)
self.etc_paths = etc_paths or []
self.copy_only = copy_only
def setup(self, old_version):
"""Create symlinks or copies of files during app update.
During the transition from shipped configs to the symlink/copy
approach, files in /etc will be removed during .deb upgrade. This
method ensures that symlinks or copies are properly recreated.
"""
if self.app_id and self.app.is_enabled():
self.enable()
def enable(self):
"""Create a symlink or copy in /etc/ of the configuration file."""
for path in self.etc_paths:
etc_path = self._get_etc_path(path)
target = self._get_target_path(path)
if etc_path.exists() or etc_path.is_symlink():
if (not self.copy_only and etc_path.is_symlink()
and etc_path.readlink() == target):
continue
if (self.copy_only and etc_path.is_file()
and etc_path.read_text() == target.read_text()):
continue
logger.warning('Removing dropin configuration: %s', path)
privileged.dropin_unlink(self.app_id, path)
privileged.dropin_link(self.app_id, path, self.copy_only)
def disable(self):
"""Remove the links/copies in /etc/ of the configuration files."""
for path in self.etc_paths:
privileged.dropin_unlink(self.app_id, path, missing_ok=True)
def diagnose(self):
"""Check all links/copies and return generate diagnostic results."""
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
results = []
for path in self.etc_paths:
etc_path = self._get_etc_path(path)
target = self._get_target_path(path)
if self.copy_only:
result = (etc_path.is_file()
and etc_path.read_text() == target.read_text())
else:
result = (etc_path.is_symlink()
and etc_path.readlink() == target)
check_id = f'dropin-config-{etc_path}'
result_string = Result.PASSED if result else Result.FAILED
template = _('Static configuration {etc_path} is setup properly')
description = format_lazy(template, etc_path=str(etc_path))
results.append(
DiagnosticCheck(check_id, description, result_string))
return results
@staticmethod
def _get_target_path(path):
"""Return Path object for a target path."""
target = pathlib.Path(DropinConfigs.ROOT)
target /= DropinConfigs.DROPIN_CONFIG_ROOT.lstrip('/')
return target / path.lstrip('/')
@staticmethod
def _get_etc_path(path):
"""Return Path object for etc path."""
return pathlib.Path(DropinConfigs.ROOT) / path.lstrip('/')