1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
|
# 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)
class Uwsgi(app.LeaderComponent):
"""Component to enable/disable uWSGI configuration."""
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
across all components.
uwsgi_name is the primary part of the configuration file path which
must be enabled/disabled by this component.
"""
super().__init__(component_id)
self.uwsgi_name = uwsgi_name
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) -> None:
"""Enable the uWSGI configuration."""
privileged.uwsgi_enable(self.uwsgi_name)
def disable(self) -> None:
"""Disable the uWSGI configuration."""
privileged.uwsgi_disable(self.uwsgi_name)
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')
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."""
command = ['curl', '--location', '-f', '-w', '%{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
|