mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-03-25 09:21:10 +00:00
[Sunil]: - Drop Uwsgi component entirely. After the changes, it mostly looks like Daemon component minus some features. One change that Uwsgi component does is when component is disabled, it also stops and disables the .service unit. Stopping the service is useful and we can add this to Daemon component. - Use /run instead of /var/run/ as 1) /var/run is a symlink to /run 2) /run/ path is what is listed in uwsgi-app@.socket unit file. - Implement upgrade for apps from older version. Disable and mask uwsgi init.d script. Enable the daemon component if the webserver component is enabled. - Update manifest files to deal with .socket units instead of 'uwsgi' service. Backup the /var/lib/private directories as that is actual directory to backup with DynamicUser=yes. - For bepasty load the configuration as a systemd provided credential since DynamicUser=yes. - Remove the /var/lib/private directories during uninstall. - Don't create user/group for bepasty as it is not needed with DynamicUser=yes. Tests: - Radicale - Functional tests pass - Freshly install radicale. - Web interface works. - Create and edit calendars - Path of the storage directory is in /var/lib/private/radicale (after accessing web interface) - Permissions on the storage folder and files inside are set to nobody:nobody. - Uninstall removes the /var/lib/private/radicale directory. - Create a calender and backup the app. Uninstall the app. Re-install the app. The calendar is not available. After restoring the backup, the calendar is available. - Install radicale without patch and create a calendar. Apply patches and start plinth.service. Setup is run. UWSGI is disabled and masked. Service is running. Old calender is visible. - Install radicale without patch. Disable and apply patches and start plinth.service. Setup is run. UWSGI is disabled and masked. Service is not running. Enabling the service works. - After upgrade, data storage path got migrated to /var/lib/private/radicale. Old data is accessible. - After upgrade the directory is still owned by radicale:radicale. - Freshly install radicale with patch and restore an old backup. The data is available in the web interface and data was migrated to /var/lib/private/radicale. - Bepasty - Functional tests pass - Freshly install bepasy. - Enabling and disabling rapidly works. - Uploading files works. - Path of the storage directory is /var/lib/private/bepasty. - Permissions on the storage folder are as expect 755 but on the parent are 700. - Permissions on the stored files are 644 and owned by nobody:nobody. - Uninstall removes the /var/lib/private/bepasty directory. - Upload a picture and backup the app. Uninstall the app. Re-install the app. The uploaded file is not available. After restoring the backup, the uploaded file is available. - Install bepasty without patch and upload a file. Apply patches and start plinth.service. Setup is run. UWSGI is disabled and masked. Service is running. Old uploaded picture is visible. - Install bepasty without patch. Disable app. Apply patches and start plinth.service. Setup is run. UWSGI is disabled and masked. Service is not running. Enabling the service works. - After upgrade, data storage path got migrated to /var/lib/private/bepasty. Old data is accessible. - After upgrade the directory is still owned by bepasty:bepasty. - Freshly install bepasty with patch and restore an old backup. The uploaded file is available in the web interface and data was migrated to /var/lib/private/bepasty. Signed-off-by: James Valleroy <jvalleroy@mailbox.org> Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
317 lines
12 KiB
Python
317 lines
12 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""App component for other apps to use Apache configuration functionality."""
|
|
|
|
import re
|
|
import subprocess
|
|
|
|
from django.utils.translation import gettext_noop
|
|
|
|
from plinth import action_utils, app, kvstore
|
|
from plinth.diagnostic_check import (DiagnosticCheck,
|
|
DiagnosticCheckParameters, Result)
|
|
from plinth.privileged import service as service_privileged
|
|
|
|
from . import privileged
|
|
|
|
|
|
class Webserver(app.LeaderComponent):
|
|
"""Component to enable/disable Apache configuration."""
|
|
|
|
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
|
|
across all components.
|
|
|
|
web_name is the primary part of the configuration file path which must
|
|
be enabled/disabled by this component.
|
|
|
|
kind is the type of Apache configuration being enabled/disabled. This
|
|
must be 'config' for a configuration in /etc/apache/conf-available/,
|
|
'module' for configuration in /etc/apache2/mods-available/, 'site' for
|
|
configuration in /etc/apache2/sites-available/.
|
|
|
|
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.
|
|
"""
|
|
super().__init__(component_id)
|
|
|
|
self.web_name = web_name
|
|
self.kind = kind
|
|
self.urls = urls or []
|
|
self.expect_redirects = expect_redirects
|
|
self.last_updated_version = last_updated_version or 0
|
|
|
|
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) -> None:
|
|
"""Enable the Apache configuration."""
|
|
privileged.enable(self.web_name, self.kind)
|
|
|
|
def disable(self) -> None:
|
|
"""Disable the Apache configuration."""
|
|
privileged.disable(self.web_name, self.kind)
|
|
|
|
def diagnose(self) -> list[DiagnosticCheck]:
|
|
"""Check if the web path is accessible by clients.
|
|
|
|
See :py:meth:`plinth.app.Component.diagnose`.
|
|
"""
|
|
results = []
|
|
for url in self.urls:
|
|
if '{host}' in url:
|
|
results.extend(
|
|
diagnose_url_on_all(url, check_certificate=False,
|
|
expect_redirects=self.expect_redirects,
|
|
component_id=self.component_id))
|
|
else:
|
|
results.append(
|
|
diagnose_url(url, 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
|
|
|
|
if self.kind == 'module':
|
|
service_privileged.restart('apache2')
|
|
else:
|
|
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 <VirtualHost> 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/<web_name>.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)
|
|
|
|
|
|
def diagnose_url(url: str, kind: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
check_certificate: bool = True,
|
|
extra_options: list[str] | None = None,
|
|
wrapper: str | None = None,
|
|
expected_output: str | None = None,
|
|
component_id: str | None = None) -> DiagnosticCheck:
|
|
"""Run a diagnostic on whether a URL is accessible.
|
|
|
|
Kind can be '4' for IPv4 or '6' for IPv6.
|
|
"""
|
|
try:
|
|
return_value = check_url(url, kind, env, check_certificate,
|
|
extra_options, wrapper, expected_output)
|
|
result = Result.PASSED if return_value else Result.FAILED
|
|
except FileNotFoundError:
|
|
result = Result.ERROR
|
|
|
|
parameters: DiagnosticCheckParameters = {'url': url, 'kind': kind}
|
|
if kind:
|
|
check_id = f'apache-url-kind-{url}-{kind}'
|
|
description = gettext_noop('Access URL {url} on tcp{kind}')
|
|
else:
|
|
check_id = f'apache-url-{url}'
|
|
description = gettext_noop('Access URL {url}')
|
|
|
|
return DiagnosticCheck(check_id, description, result, parameters,
|
|
component_id)
|
|
|
|
|
|
def diagnose_url_on_all(url: str, expect_redirects: bool = False,
|
|
component_id: str | None = None,
|
|
**kwargs) -> list[DiagnosticCheck]:
|
|
"""Run a diagnostic on whether a URL is accessible."""
|
|
results = []
|
|
for address in action_utils.get_addresses():
|
|
current_url = url.format(host=address['url_address'])
|
|
diagnose_kwargs = dict(kwargs)
|
|
if not expect_redirects:
|
|
diagnose_kwargs.setdefault('kind', address['kind'])
|
|
|
|
results.append(
|
|
diagnose_url(current_url, component_id=component_id,
|
|
**diagnose_kwargs))
|
|
|
|
return results
|
|
|
|
|
|
def check_url(url: str, kind: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
check_certificate: bool = True,
|
|
extra_options: list[str] | None = None,
|
|
wrapper: str | None = None,
|
|
expected_output: str | None = None) -> bool:
|
|
"""Check whether a URL is accessible."""
|
|
# When testing a URL with cURL, following any redirections with --location.
|
|
# During those follows, store cookies that have been set and use them for
|
|
# later requests. mod_auth_openidc will set a cookie 'x_csrf' to prevent
|
|
# CSRF attacks and expect this cookie to sent back to it in later requests.
|
|
# If this cookie is not present, it will refuse to perform OIDC Discovery
|
|
# process resulting 404 errors and diagnostic failures for domains that
|
|
# have not been visited by a user recently. --cookie '' means the cURL will
|
|
# use an in-process cookie-jar for storing and retrieving cookies without
|
|
# writing to a file on the disk.
|
|
command = [
|
|
'curl', '--location', '--cookie', '', '--fail', '--write-out',
|
|
'%{response_code}'
|
|
]
|
|
|
|
if kind == '6':
|
|
# extract zone index
|
|
match = re.match(r'(.*://)\[(.*)%(?P<zone>.*)\](.*)', url)
|
|
if match:
|
|
command = command + ['--interface', match.group('zone')]
|
|
url = '{0}[{1}]{2}'.format(*match.group(1, 2, 4))
|
|
|
|
command.append(url)
|
|
|
|
if wrapper:
|
|
command.insert(0, wrapper)
|
|
|
|
if not check_certificate:
|
|
command.append('-k')
|
|
|
|
if extra_options:
|
|
command.extend(extra_options)
|
|
|
|
if kind:
|
|
command.append({'4': '-4', '6': '-6'}[kind])
|
|
|
|
try:
|
|
process = subprocess.run(command, env=env, check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
result = True
|
|
if expected_output and expected_output not in process.stdout.decode():
|
|
result = False
|
|
except subprocess.CalledProcessError as exception:
|
|
result = False
|
|
# Authorization failed is a success
|
|
if exception.stdout.decode().strip() in ('401', '405'):
|
|
result = True
|
|
|
|
return result
|