mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-01 09:30:29 +00:00
freedombox Debian release 24.7
-----BEGIN PGP SIGNATURE----- iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmYCOD4WHGp2YWxsZXJv eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICLXeD/9D+2xGCccqxDBat8+6eawVwwdK LluLbz+t0d3ZR+IMla/LhE+QdLJFyh1H3Is+QgSpF+3iowobFf8iqYUT/L0obOpH ICdyLOuwTC0qb9fsSowFMh4m7gW9xzRzFxdKdWCLVqDnLZJfULOtZJFcmMuVp54Z 8J+F0DAgun0/h8TMfo7Kzr+ee9sAiras6nveL+Keu9ib+qXE9/sFLX/T6mLw0Ad4 Wo8gY73vpuQRV4NvKCQa/mNDLX5ANGIywxynkblwNfQMkOmu8J7AHDEQV5/rHw4q HIv8mPryn1SC56NeZmFt06hskJ4DoHnpPBrxi6GbMlOcD64WPu10DKnu9RUPIhhg 1X3dTwBkhfFLS8CJo9KUT36C5VFfw1FfSQt3ngpuhIq2ckRdGoKzHLYY5bo10C5L vrGPIC4IEI/3b8leoT6elSFmbBfsmRkZwrAJosqOOEEB+4+q2wjsZ7SfvLjlKOGI NK6kOwEfwBO4wug4mrU7LSxq46PJ6kVXLWt0v/9GFRhWlpE9wiDts6hdW4CxTAwF qgq4iKuxmhUX5bmWCtscIa+0VwavGN5vrfZcIPV1itCSq2Pv6lTtXWVWV4q2bGyX evZlSEf8Y4D/Wf8FE6jwN4HqspeF9cMxdLRF8Sw5VaZMBWttIpSC5EdDM0PVtECA Ya/+QrWHuBiz/DSB9Q== =OEE1 -----END PGP SIGNATURE----- gpgsig -----BEGIN PGP SIGNATURE----- iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmYHPkcWHGp2YWxsZXJv eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICCDbD/98zlpb1JNUOYbzY8uujMWzYaMG huCgCbiwpW+ygPdWKTjSgEnn7IsUt6jk72yOANfEFVoiaIufVnHPWc9LARFdHYEs wI6luiDlZuxQwEIS6PkFpcZPHG+fFRZrJyLK3f1ggTmFAMrrWT42fOv6mVJovRBr 3a62sO8JnRAStv3TnVz+61Pj+fv12bpxBRukCKS2mrmLZapahTrrEYQvuCvkrzTO Gj70O4ka/jCrfhT12rDEvwsUIln2u8KB5+M4aLd78LCsm8OvEEaHdqpWo5wE5Vgt NkgI0qSKuOfLut6s6V8t3Soe7+GjO2x7GoJQC8x0gj0bYd8eYN2zZWICzTk6mgwe lkhHnG7CYjxJ4oDbwjKARi2pPTEpcCjOZlH5JcCy8tejdchM6+vLMpyta5l4om1Y NQgKQ1WlqeaG7knVK2eFZKOtsMTOpCxmStEOA7TUVTGj51dZXow1VBP0T+EecH2o wzM1GU16h0tKHZBZrGonhG4/OMhTga8wPex+9taI1GjNVAWeUt4dexldo+kmOMjJ bQ5J1C46MTLgSlbwktmUbSLFyd6NZMw9FINiQKOn7NgKsHNxo2Yh0TDSiYJaH4qr lL+v+QIk8sVcygnKhYKOGTKRw7Tczkr3iTcouEVUVX9CKhAKjgeD0ZJTly5t0ss+ FuDyWd4iJOCl81YNSw== =IrNE -----END PGP SIGNATURE----- Merge tag 'v24.7' into debian/bookworm-backports freedombox Debian release 24.7 Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
commit
738eff2ff2
207
actions/actions
207
actions/actions
@ -1,210 +1,7 @@
|
||||
#!/usr/bin/python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
import types
|
||||
import typing
|
||||
|
||||
import plinth.log
|
||||
from plinth import cfg, module_loader
|
||||
|
||||
EXIT_SYNTAX = 10
|
||||
EXIT_PERM = 20
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"""Parse arguments."""
|
||||
plinth.log.action_init()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('module', help='Module to trigger action in')
|
||||
parser.add_argument('action', help='Action to trigger in module')
|
||||
parser.add_argument('--write-fd', type=int, default=1,
|
||||
help='File descriptor to write output to')
|
||||
parser.add_argument('--no-args', default=False, action='store_true',
|
||||
help='Do not read arguments from stdin')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
try:
|
||||
arguments = {'args': [], 'kwargs': {}}
|
||||
if not args.no_args:
|
||||
input_ = sys.stdin.read()
|
||||
if input_:
|
||||
arguments = json.loads(input_)
|
||||
except json.JSONDecodeError as exception:
|
||||
raise SyntaxError('Arguments on stdin not JSON.') from exception
|
||||
|
||||
return_value = _call(args.module, args.action, arguments)
|
||||
with os.fdopen(args.write_fd, 'w') as write_file_handle:
|
||||
write_file_handle.write(json.dumps(return_value))
|
||||
except PermissionError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_PERM)
|
||||
except SyntaxError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_SYNTAX)
|
||||
except TypeError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_SYNTAX)
|
||||
except Exception as exception:
|
||||
logger.exception(exception)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _call(module_name, action_name, arguments):
|
||||
"""Import the module and run action as superuser"""
|
||||
if '.' in module_name:
|
||||
raise SyntaxError('Invalid module name')
|
||||
|
||||
cfg.read()
|
||||
if module_name == 'plinth':
|
||||
import_path = 'plinth'
|
||||
else:
|
||||
import_path = module_loader.get_module_import_path(module_name)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(import_path + '.privileged')
|
||||
except ModuleNotFoundError as exception:
|
||||
raise SyntaxError('Specified module not found') from exception
|
||||
|
||||
try:
|
||||
action = getattr(module, action_name)
|
||||
except AttributeError as exception:
|
||||
raise SyntaxError('Specified action not found') from exception
|
||||
|
||||
if not getattr(action, '_privileged', None):
|
||||
raise SyntaxError('Specified action is not privileged action')
|
||||
|
||||
func = getattr(action, '__wrapped__')
|
||||
|
||||
_assert_valid_arguments(func, arguments)
|
||||
|
||||
try:
|
||||
return_values = func(*arguments['args'], **arguments['kwargs'])
|
||||
return_value = {'result': 'success', 'return': return_values}
|
||||
except Exception as exception:
|
||||
logger.exception('Error executing action: %s', exception)
|
||||
return_value = {
|
||||
'result': 'exception',
|
||||
'exception': {
|
||||
'module': type(exception).__module__,
|
||||
'name': type(exception).__name__,
|
||||
'args': exception.args,
|
||||
'traceback': traceback.format_tb(exception.__traceback__)
|
||||
}
|
||||
}
|
||||
|
||||
return return_value
|
||||
|
||||
|
||||
def _assert_valid_arguments(func, arguments):
|
||||
"""Check the names, types and completeness of the arguments passed."""
|
||||
# Check if arguments match types
|
||||
if not isinstance(arguments, dict):
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
if 'args' not in arguments or 'kwargs' not in arguments:
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
args = arguments['args']
|
||||
kwargs = arguments['kwargs']
|
||||
if not isinstance(args, list) or not isinstance(kwargs, dict):
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
argspec = inspect.getfullargspec(func)
|
||||
if len(args) + len(kwargs) > len(argspec.args):
|
||||
raise SyntaxError('Too many arguments')
|
||||
|
||||
no_defaults = len(argspec.args)
|
||||
if argspec.defaults:
|
||||
no_defaults -= len(argspec.defaults)
|
||||
|
||||
for key in argspec.args[len(args):no_defaults]:
|
||||
if key not in kwargs:
|
||||
raise SyntaxError(f'Argument not provided: {key}')
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in argspec.args:
|
||||
raise SyntaxError(f'Unknown argument: {key}')
|
||||
|
||||
if argspec.args.index(key) < len(args):
|
||||
raise SyntaxError(f'Duplicate argument: {key}')
|
||||
|
||||
_assert_valid_type(f'arg {key}', value, argspec.annotations[key])
|
||||
|
||||
for index, arg in enumerate(args):
|
||||
annotation = argspec.annotations[argspec.args[index]]
|
||||
_assert_valid_type(f'arg #{index}', arg, annotation)
|
||||
|
||||
|
||||
def _assert_valid_type(arg_name, value, annotation):
|
||||
"""Assert that the type of argument value matches the annotation."""
|
||||
if annotation == typing.Any:
|
||||
return
|
||||
|
||||
NoneType = type(None)
|
||||
if annotation == NoneType:
|
||||
if value is not None:
|
||||
raise TypeError('Expected None for {arg_name}')
|
||||
|
||||
return
|
||||
|
||||
basic_types = {bool, int, str, float}
|
||||
if annotation in basic_types:
|
||||
if not isinstance(value, annotation):
|
||||
raise TypeError(
|
||||
f'Expected type {annotation.__name__} for {arg_name}')
|
||||
|
||||
return
|
||||
|
||||
# 'int | str' or 'typing.Union[int, str]'
|
||||
if (isinstance(annotation, types.UnionType)
|
||||
or getattr(annotation, '__origin__', None) == typing.Union):
|
||||
for arg in annotation.__args__:
|
||||
try:
|
||||
_assert_valid_type(arg_name, value, arg)
|
||||
return
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
raise TypeError(f'Expected one of unioned types for {arg_name}')
|
||||
|
||||
# 'list[int]' or 'typing.List[int]'
|
||||
if getattr(annotation, '__origin__', None) == list:
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f'Expected type list for {arg_name}')
|
||||
|
||||
for index, inner_item in enumerate(value):
|
||||
_assert_valid_type(f'{arg_name}[{index}]', inner_item,
|
||||
annotation.__args__[0])
|
||||
|
||||
return
|
||||
|
||||
# 'list[dict]' or 'typing.List[dict]'
|
||||
if getattr(annotation, '__origin__', None) == dict:
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f'Expected type dict for {arg_name}')
|
||||
|
||||
for inner_key, inner_value in value.items():
|
||||
_assert_valid_type(f'{arg_name}[{inner_key}]', inner_key,
|
||||
annotation.__args__[0])
|
||||
_assert_valid_type(f'{arg_name}[{inner_value}]', inner_value,
|
||||
annotation.__args__[1])
|
||||
|
||||
return
|
||||
|
||||
raise TypeError('Unsupported annotation type')
|
||||
|
||||
from plinth.actions import privileged_main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
privileged_main()
|
||||
|
||||
247
conftest.py
247
conftest.py
@ -1,255 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
pytest configuration for all tests.
|
||||
pytest configuration that needs to be pytest rootdir.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
importlib.import_module('splinter')
|
||||
importlib.import_module('selenium')
|
||||
_functional_libs_available = True
|
||||
except ImportError:
|
||||
_functional_libs_available = False
|
||||
|
||||
|
||||
def pytest_ignore_collect(path, config):
|
||||
"""Ignore functional tests when splinter is not available."""
|
||||
if path.basename == 'test_functional.py':
|
||||
return not _functional_libs_available
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add a command line option to run functional tests."""
|
||||
parser.addoption('--include-functional', action='store_true',
|
||||
default=False, help='Run functional tests also')
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Filter out specificly marked tests unless explicitly requested.
|
||||
|
||||
The EXTENDED_TESTING environment variable is borrowed from the Lancaster
|
||||
consensus met by the Pearl community. See
|
||||
https://github.com/Perl-Toolchain-Gang/toolchain-site/blob/master/lancaster-consensus.md
|
||||
"""
|
||||
|
||||
def skip(item, reason):
|
||||
item.add_marker(pytest.mark.skip(reason=reason))
|
||||
|
||||
extended = 'EXTENDED_TESTING' in os.environ
|
||||
if not (extended or config.getoption('--include-functional')):
|
||||
for item in items:
|
||||
if 'functional' in item.keywords or (
|
||||
item.parent.fspath.basename
|
||||
and item.parent.fspath.basename == 'test_functional.py'):
|
||||
skip(item, '--include-functional not provided')
|
||||
|
||||
if not extended:
|
||||
for item in items:
|
||||
if 'heavy' in item.keywords:
|
||||
skip(item, ('Takes too much time. '
|
||||
'Set EXTENDED_TESTING=1 to force run'))
|
||||
|
||||
|
||||
@pytest.fixture(name='load_cfg')
|
||||
def fixture_load_cfg():
|
||||
"""Load test configuration."""
|
||||
from plinth import cfg
|
||||
|
||||
keys = ('file_root', 'config_dir', 'data_dir', 'custom_static_dir',
|
||||
'store_file', 'actions_dir', 'doc_dir', 'server_dir', 'host',
|
||||
'port', 'use_x_forwarded_for', 'use_x_forwarded_host',
|
||||
'secure_proxy_ssl_header', 'box_name', 'develop')
|
||||
saved_state = {}
|
||||
for key in keys:
|
||||
saved_state[key] = getattr(cfg, key)
|
||||
|
||||
root_dir = pathlib.Path(__file__).resolve().parent
|
||||
cfg_file = root_dir / 'plinth' / 'develop.config'
|
||||
cfg.read_file(str(cfg_file))
|
||||
yield cfg
|
||||
|
||||
for key in keys:
|
||||
setattr(cfg, key, saved_state[key])
|
||||
|
||||
|
||||
@pytest.fixture(name='develop_mode')
|
||||
def fixture_develop_mode(load_cfg):
|
||||
"""Turn on development mode for a test."""
|
||||
load_cfg.develop = True
|
||||
yield
|
||||
load_cfg.develop = False
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_root', scope='session')
|
||||
def fixture_needs_root():
|
||||
"""Skip test if not running in root mode."""
|
||||
if os.geteuid() != 0:
|
||||
pytest.skip('Needs to be root')
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_not_root', scope='session')
|
||||
def fixture_needs_not_root():
|
||||
"""Skip test if running in root mode."""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip('Needs not to be root')
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_sudo')
|
||||
def fixture_needs_sudo():
|
||||
"""Skip test if sudo command is not available."""
|
||||
if not os.path.isfile('/usr/bin/sudo'):
|
||||
pytest.skip('Needs sudo command installed.')
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_selenium_implicit_wait():
|
||||
"""Disable implicit waiting."""
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_wait_time():
|
||||
"""Disable explicit waiting."""
|
||||
return 0.01
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_browser_load_condition():
|
||||
"""When a page it loaded, wait until <body> is available."""
|
||||
|
||||
def _load_condition(browser):
|
||||
if browser.url == 'about:blank':
|
||||
return True
|
||||
|
||||
ready_state = browser.execute_script('return document.readyState;')
|
||||
return ready_state == 'complete'
|
||||
|
||||
return _load_condition
|
||||
|
||||
|
||||
@pytest.fixture(name='actions_module', scope='module')
|
||||
def fixture_actions_module(request):
|
||||
"""Import and return an action module."""
|
||||
actions_name = getattr(request.module, 'actions_name')
|
||||
actions_file = str(
|
||||
pathlib.Path(__file__).parent / 'actions' / actions_name)
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader(actions_name, actions_file)
|
||||
spec = importlib.util.spec_from_loader(actions_name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[actions_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(name='mock_privileged')
|
||||
def fixture_mock_privileged(request):
|
||||
"""Mock the privileged decorator to nullify its effects."""
|
||||
try:
|
||||
privileged_modules_to_mock = request.module.privileged_modules_to_mock
|
||||
except AttributeError:
|
||||
raise AttributeError(
|
||||
'mock_privileged fixture requires "privileged_module_to_mock" '
|
||||
'attribute at module level')
|
||||
|
||||
for module_name in privileged_modules_to_mock:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, member in module.__dict__.items():
|
||||
wrapped = getattr(member, '__wrapped__', None)
|
||||
if not callable(member) or not wrapped:
|
||||
continue
|
||||
|
||||
if not getattr(member, '_privileged', False):
|
||||
continue
|
||||
|
||||
setattr(wrapped, '_original_wrapper', member)
|
||||
module.__dict__[name] = wrapped
|
||||
|
||||
yield
|
||||
|
||||
for module_name in privileged_modules_to_mock:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, member in module.__dict__.items():
|
||||
wrapper = getattr(member, '_original_wrapper', None)
|
||||
if not callable(member) or not wrapper:
|
||||
continue
|
||||
|
||||
module.__dict__[name] = wrapper
|
||||
|
||||
|
||||
@pytest.fixture(name='splinter_screenshot_dir', scope='session')
|
||||
def fixture_splinter_screenshot_dir(request):
|
||||
"""Set default screenshot directory to ./screenshots.
|
||||
|
||||
This can be overridden using --splinter-screenshot-dir=foo as the option.
|
||||
"""
|
||||
option = request.config.getoption('--splinter-screenshot-dir')
|
||||
screenshots_dir = option if option != '.' else './screenshots'
|
||||
return os.path.abspath(screenshots_dir)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixture_fix_session_browser_screenshots(request):
|
||||
"""Fix a bug in pytest-splinter for screenshots.
|
||||
|
||||
When using session_browser, pytest-splinter does not take a screenshot when
|
||||
a test has failed. It is uses internal pytest API on the FixtureRequest
|
||||
object. This API was removed in later versions of pytest causing the
|
||||
failure. Re-implement the fixture that has the problem fixing this issue.
|
||||
|
||||
Drop this fixture after a fix is merged and released in pytest-splinter.
|
||||
See: https://github.com/pytest-dev/pytest-splinter/pull/157
|
||||
"""
|
||||
yield
|
||||
|
||||
if not request.config.pluginmanager.has_plugin('pytest-splinter'):
|
||||
return
|
||||
|
||||
session_tmpdir = request.getfixturevalue('session_tmpdir')
|
||||
splinter_session_scoped_browser = request.getfixturevalue(
|
||||
'splinter_session_scoped_browser')
|
||||
splinter_make_screenshot_on_failure = request.getfixturevalue(
|
||||
'splinter_make_screenshot_on_failure')
|
||||
splinter_screenshot_dir = request.getfixturevalue(
|
||||
'splinter_screenshot_dir')
|
||||
splinter_screenshot_getter_html = request.getfixturevalue(
|
||||
'splinter_screenshot_getter_html')
|
||||
splinter_screenshot_getter_png = request.getfixturevalue(
|
||||
'splinter_screenshot_getter_png')
|
||||
splinter_screenshot_encoding = request.getfixturevalue(
|
||||
'splinter_screenshot_encoding')
|
||||
|
||||
# Screenshot for function scoped browsers is handled in
|
||||
# browser_instance_getter
|
||||
if not splinter_session_scoped_browser:
|
||||
return
|
||||
|
||||
for name in request.fixturenames:
|
||||
fixture_def = request._fixture_defs.get(name)
|
||||
if not fixture_def or not fixture_def.cached_result:
|
||||
continue
|
||||
|
||||
value = fixture_def.cached_result[0]
|
||||
should_take_screenshot = (hasattr(value, "__splinter_browser__")
|
||||
and splinter_make_screenshot_on_failure
|
||||
and getattr(request.node, 'splinter_failure',
|
||||
True))
|
||||
|
||||
from pytest_splinter import plugin
|
||||
if should_take_screenshot:
|
||||
plugin._take_screenshot(
|
||||
request=request,
|
||||
fixture_name=name,
|
||||
session_tmpdir=session_tmpdir,
|
||||
browser_instance=value,
|
||||
splinter_screenshot_dir=splinter_screenshot_dir,
|
||||
splinter_screenshot_getter_html=splinter_screenshot_getter_html,
|
||||
splinter_screenshot_getter_png=splinter_screenshot_getter_png,
|
||||
splinter_screenshot_encoding=splinter_screenshot_encoding,
|
||||
)
|
||||
|
||||
83
debian/changelog
vendored
83
debian/changelog
vendored
@ -1,3 +1,86 @@
|
||||
freedombox (24.7) unstable; urgency=medium
|
||||
|
||||
[ Sunil Mohan Adapa ]
|
||||
* actions: Move most of the privileged action code to main directory
|
||||
* tests: Remove unused fixture for testing actions
|
||||
* tests: Move test configuration to plinth directory
|
||||
* tests: Merge actions related test files
|
||||
* tests: Automatically create pytest marks for apps
|
||||
* users: Add email address field when creating/updating user accounts
|
||||
* users: Add email address field during first boot
|
||||
* system: Organize items into sections
|
||||
* views: Fix alignment of close button in error messages
|
||||
* actions: Minor refactor to action error logging
|
||||
* actions: Provide HTML error message with action error
|
||||
* views: Implement a utility to easily show error message
|
||||
* middleware: Show HTML exception message as extra detail in messages
|
||||
* package: Drop special error message handling for package errors
|
||||
* backups: Adjust to changes in privileged errors
|
||||
* letsencrypt: Simplify error warning when certificate revoke fails
|
||||
* letsencrypt: Show better error messages
|
||||
* storage: Adjust to changes in privileged errors
|
||||
* letsencrypt: Remove unnecessary processing of the error messages
|
||||
* storage: Show better error message
|
||||
* upgrades: Show better error messages
|
||||
* snapshot: Show better error messages
|
||||
* package: Don't remove packages of other apps on uninstall
|
||||
* matrixsynapse: Prevent setup page from being shown during uninstall
|
||||
|
||||
[ Veiko Aasa ]
|
||||
* samba: Fix Samba not accessible from IPv6 localhost ::1 address
|
||||
* samba: Disable nmbd NetBIOS service
|
||||
|
||||
[ James Valleroy ]
|
||||
* locale: Update translation strings
|
||||
* doc: Fetch latest manual
|
||||
|
||||
-- James Valleroy <jvalleroy@mailbox.org> Mon, 25 Mar 2024 21:12:59 -0400
|
||||
|
||||
freedombox (24.6) unstable; urgency=medium
|
||||
|
||||
[ Veiko Aasa ]
|
||||
* gitweb: Fix modifying git repositories when gitweb app is disabled
|
||||
* users: tests: Do not remove LDAP user when testing views
|
||||
* samba: Ignore non-existent users who are in freedombox-share group
|
||||
|
||||
[ ikmaak ]
|
||||
* Translated using Weblate (Dutch)
|
||||
|
||||
[ James Valleroy ]
|
||||
* diagnostics: Add tests for get_results
|
||||
* diagnostics: Handle TypeError when copying results
|
||||
* locale: Update translation strings
|
||||
* doc: Fetch latest manual
|
||||
|
||||
[ Sunil Mohan Adapa ]
|
||||
* users: Fix creating users with initial set of groups
|
||||
* users: Minor refactor when creating django groups
|
||||
* log: Don't log with in color inside actions scripts
|
||||
* actions: Fix log message when action return can't be decoded
|
||||
* actions: When action errors out, log a better message
|
||||
* *: Add type hints for app init methods
|
||||
* *: Add type hints for diagnose method
|
||||
* action_utils: Implement method for starting a service temporarily
|
||||
* zoph: Don't fail setup if mysql installed but not running
|
||||
* wordpress: Don't fail setup if mysql installed but not running
|
||||
* app: Add ability to hide configuration form when app is disabled
|
||||
* zoph: Hide configuration form when app is disabled
|
||||
* app: views: Expose method to get enabled/disabled state and cache it
|
||||
* zoph: Don't redirect to setup page when app is disabled
|
||||
* zoph: Don't fail with backup/restore if app is disabled
|
||||
* zoph: Uninstall fully so that reinstall works
|
||||
* daemon: Added method to ensure a daemon is running in component
|
||||
* zoph: Ensure that database server is running when setting up app
|
||||
* wordpress: Fix backup, restore and uninstall when db is not running
|
||||
* wordpress: Drop database user when app is uninstalled
|
||||
* tests: functional: Uninstall app after backup and before restore
|
||||
* zoph: Restore database password to old value after restore operation
|
||||
* wordpress: tests: Uninstall app after backup and before restore
|
||||
* tests: functional: Refactor install/setup fixture for apps
|
||||
* wordpress: Fix minor issue in restoring database
|
||||
|
||||
-- James Valleroy <jvalleroy@mailbox.org> Mon, 11 Mar 2024 20:40:48 -0400
|
||||
|
||||
freedombox (24.5~bpo12+1) bookworm-backports; urgency=medium
|
||||
|
||||
* Rebuild for bookworm-backports.
|
||||
|
||||
@ -37,8 +37,8 @@ Base Classes
|
||||
Other Classes
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
.. autoclass:: plinth.modules.diagnostics.check.DiagnosticCheck
|
||||
.. autoclass:: plinth.diagnostic_check.DiagnosticCheck
|
||||
:members:
|
||||
|
||||
.. autoclass:: plinth.modules.diagnostics.check.Result
|
||||
.. autoclass:: plinth.diagnostic_check.Result
|
||||
:members:
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
!FreedomBox is a [[DebianPureBlends|pure blend]] of Debian. This means that all the work on !FreedomBox is available in Debian as packages. It also means that any machine running Debian can be turned into a !FreedomBox.
|
||||
|
||||
This page describes the process of installing !FreedomBox on a Debian system. Currently, !FreedomBox works in Debian Stable (Bullseye), Testing (Bookworm), and Unstable (Sid).
|
||||
This page describes the process of installing !FreedomBox on a Debian system. Currently, !FreedomBox works in Debian Stable (bookworm), Testing (trixie), and Unstable (sid).
|
||||
|
||||
'''Important:''' Read [[FreedomBox/Hardware|general advice]] about hardware before building a !FreedomBox with this approach.
|
||||
|
||||
@ -29,11 +29,11 @@ Installing !FreedomBox changes your Debian system in many important ways. This
|
||||
After !FreedomBox is fully setup, your system will no longer allow users not belonging to the ''admin'' group to log in to the system via console, secure shell (SSH) or graphical login. This behaviour can be disabled from the [[FreedomBox/Manual/Security|Security]] page. Use the administrator account created during !FreedomBox first boot for console logins and add further user accounts to ''admin'' group, if necessary.
|
||||
}}}
|
||||
|
||||
=== Installing on Debian 11 (Bullseye) or newer ===
|
||||
=== Installing on Debian 12 (bookworm) or newer ===
|
||||
|
||||
Check the Troubleshooting section below, for any tips or workarounds that might help during the install.
|
||||
|
||||
1. [[InstallingDebianOn|Install Debian]] 11 (Bullseye), or Unstable (Sid) on your hardware.
|
||||
1. [[InstallingDebianOn|Install Debian]] 12 (bookworm), or Unstable (sid) on your hardware.
|
||||
|
||||
1. Update your package list.
|
||||
|
||||
|
||||
@ -8,6 +8,81 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
|
||||
|
||||
The following are the release notes for each !FreedomBox version.
|
||||
|
||||
== FreedomBox 24.7 (2024-03-25) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* package: Don't remove packages of other apps on uninstall
|
||||
* samba: Fix Samba not accessible from IPv6 localhost ::1 address
|
||||
* system: Organize items into sections
|
||||
* users: Add email address field when creating/updating user accounts
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* actions: Minor refactor to action error logging
|
||||
* actions: Move most of the privileged action code to main directory
|
||||
* actions: Provide HTML error message with action error
|
||||
* backups: Adjust to changes in privileged errors
|
||||
* letsencrypt: Remove unnecessary processing of the error messages
|
||||
* letsencrypt: Show better error messages
|
||||
* letsencrypt: Simplify error warning when certificate revoke fails
|
||||
* matrixsynapse: Prevent setup page from being shown during uninstall
|
||||
* middleware: Show HTML exception message as extra detail in messages
|
||||
* package: Drop special error message handling for package errors
|
||||
* samba: Disable nmbd NetBIOS service
|
||||
* snapshot: Show better error messages
|
||||
* storage: Adjust to changes in privileged errors
|
||||
* storage: Show better error message
|
||||
* tests: Automatically create pytest marks for apps
|
||||
* tests: Merge actions related test files
|
||||
* tests: Move test configuration to plinth directory
|
||||
* tests: Remove unused fixture for testing actions
|
||||
* upgrades: Show better error messages
|
||||
* users: Add email address field during first boot
|
||||
* views: Fix alignment of close button in error messages
|
||||
* views: Implement a utility to easily show error message
|
||||
|
||||
== FreedomBox 24.6 (2024-03-11) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* gitweb: Fix modifying git repositories when gitweb app is disabled
|
||||
* users: Fix creating users with initial set of groups
|
||||
* wordpress:
|
||||
* Don't fail setup if mysql installed but not running
|
||||
* Drop database user when app is uninstalled
|
||||
* Fix backup, restore and uninstall when db is not running
|
||||
* zoph:
|
||||
* Don't fail setup if mysql installed but not running
|
||||
* Don't fail with backup/restore if app is disabled
|
||||
* Don't redirect to setup page when app is disabled
|
||||
* Ensure that database server is running when setting up app
|
||||
* Hide configuration form when app is disabled
|
||||
* Restore database password to old value after restore operation
|
||||
* Uninstall fully so that reinstall works
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* *: Add type hints for app init methods
|
||||
* *: Add type hints for diagnose method
|
||||
* action_utils: Implement method for starting a service temporarily
|
||||
* actions: Fix log message when action return can't be decoded
|
||||
* actions: When action errors out, log a better message
|
||||
* app: Add ability to hide configuration form when app is disabled
|
||||
* app: views: Expose method to get enabled/disabled state and cache it
|
||||
* daemon: Added method to ensure a daemon is running in component
|
||||
* diagnostics: Add tests for get_results
|
||||
* diagnostics: Handle !TypeError when copying results
|
||||
* locale: Update translations for Dutch
|
||||
* log: Don't log with in color inside actions scripts
|
||||
* samba: Ignore non-existent users who are in freedombox-share group
|
||||
* tests: functional: Refactor install/setup fixture for apps
|
||||
* tests: functional: Uninstall app after backup and before restore
|
||||
* users: Minor refactor when creating django groups
|
||||
* users: tests: Do not remove LDAP user when testing views
|
||||
* wordpress: Fix minor issue in restoring database
|
||||
* wordpress: tests: Uninstall app after backup and before restore
|
||||
|
||||
== FreedomBox 24.5 (2024-02-26) ==
|
||||
|
||||
* backups: tests: Don't use pytest marks on fixtures
|
||||
|
||||
@ -40,17 +40,37 @@ The "fbx" user also has superuser privileges via ``sudo``.
|
||||
|
||||
=== Logging In ===
|
||||
|
||||
==== Local ====
|
||||
==== Who can log in to FreedomBox by SSH? ====
|
||||
!FreedomBox administrative users may use SSH to to log in to !FreedomBox. The user 'fbx' is created by !FreedomBox and is an administrative super-user. There are options which allow ordinary users to log in:
|
||||
* SSH access can be granted to specific users in the Edit User page by selecting the option, "Remotely login using Secure Shell (SSH) (freedombox-ssh)"
|
||||
* SSH access can be granted globally to all users in the SSH configuration page by selecting the, "Allow all users to login remotely," option.
|
||||
|
||||
With a new !FreedomBox you may log in as fbx using ssh, and other ordinary users will be able to log in after adjusting the user or Secure Shell settings above in this section. The root user account will have no password set and will not be able to log in.
|
||||
|
||||
==== SSH Client Software ====
|
||||
SSH client in included in many operating systems including Linux, Microsoft Windows, and Apple MacOS. SSH is included in Chromebooks, but requires some configuration by the user. In most cases you can run SSH from a terminal or command prompt as shown here, using your !FreedomBox hostname or IP address:
|
||||
{{{
|
||||
$ ssh freedombox.local
|
||||
}}}
|
||||
|
||||
If your client computer does not have SSH available, PuTTY is a popular free software client program which complies with the Debian Free Software Guidelines. PuTTY has a graphical interface to remember and manage your SSH connections. See External links below for more information about PuTTY.
|
||||
|
||||
===== Cockpit as an SSH Alternative =====
|
||||
The Cockpit Server Administration Terminal app available from the Cockpit Tools menu is an alternative shell access tool to SSH. Like SSH your connection to a !FreedomBox terminal is secured. Cockpit is a good choice for users who do not wish to enable the SSH server or those who prefer to connect through a web browser. With either tool you will be presented with the !FreedomBox bash command line interface.
|
||||
|
||||
Some users prefer to run SSH instead of, or in addition to, Cockpit. Command shell users tend to like SSH because it's something that they are already using. Users with Linux or Unix system administration experience tend to rely on this connection method because it is a simpler service which is thought to be more likely to be available if problems arise.
|
||||
|
||||
Refer to the Let's Encrypt and Cockpit sections of this manual to configure Cockpit and SSL certificates for security.
|
||||
|
||||
==== SSH over Local Network ====
|
||||
|
||||
To login via SSH, to your !FreedomBox:
|
||||
|
||||
{{{
|
||||
$ ssh fbx@freedombox
|
||||
$ ssh fbx@freedombox.local
|
||||
}}}
|
||||
|
||||
Replace `fbx` with the name of the user you wish to login as. `freedombox` should be replaced with the hostname or IP address of you !FreedomBox device as found in the [[FreedomBox/Manual/QuickStart|Quick Start]] process.
|
||||
|
||||
`fbx` is the default user present on !FreedomBox with superuser privileges. Any other user created using !FreedomBox and belonging to the group `admin` will be able to login. The `root` account has no password set and will not be able to login. Access will be denied to all other users.
|
||||
Replace `fbx` with the name of the user you wish to login as. `freedombox` should be replaced with the hostname or IP address of you !FreedomBox device as found in the [[FreedomBox/Manual/QuickStart|Quick Start]] process.
|
||||
|
||||
`fbx` and users in `admin` group will also be able to login on the terminal directly. Other users will be denied access.
|
||||
|
||||
@ -145,11 +165,150 @@ $ passwd
|
||||
|
||||
This will ask you for your current password before giving you the opportunity to set a new one.
|
||||
|
||||
=== SSH Keys ===
|
||||
The next step for SSH security and convenience is to understand and use ssh keys. If you logged in to !FreedomBox the first time using ssh following the instructions above you specified a username and password to log in. In this section you'll learn about Server Fingerprints and host keys, authorized keys, and reasons to use these to make connection easier and more secure.
|
||||
|
||||
By default SSH is configured to prefer to use keys while still allowing you to use a username and password to log in. At the end of this section you will be able to:
|
||||
* Connect to !FreedomBox and know that you are connecting to the right computer.
|
||||
* Connect instantly without giving a username and password.
|
||||
* Further improve the security of your !FreedomBox by disabling SSH password authentication.
|
||||
|
||||
==== SSH Public and Private Keys ====
|
||||
SSH keys are generated in pairs called a key pair. There is a public key and a private key for each key pair. The public key encrypts data which can only be read using the private key, and the private key encrypts data which can only be read using the public key. This is called an asymmetric cryptography system. SSH will distribute your public keys automatically to the other connected system while keeping your private keys safe.
|
||||
|
||||
Using SSH keys creates a powerful set of security features:
|
||||
* You are assured that you are connected to your !FreedomBox.
|
||||
* Nobody will be able to read or modify your ssh communication to !FreedomBox.
|
||||
* The !FreedomBox SSH server will know you are the remote user connected.
|
||||
* Nobody will be able to read or modify your ssh communication from !FreedomBox.
|
||||
* Connection is automatic with no username or password.
|
||||
* Your !FreedomBox can block any password guessing attack.
|
||||
|
||||
==== Create your personal SSH keys on your client computer using ssh-keygen ====
|
||||
You will create an SSH key pair on your client computer. We'll use the defaults and will not specify a password. Just press the Enter key when you are prompted for an SSH key password. This is very simple using the ssh-keygen command with no arguments. Here is how to run the command and a sample of the output the ssh-keygen program will give to you:
|
||||
{{{
|
||||
$ ssh-keygen
|
||||
Generating public/private rsa key pair.
|
||||
Enter file in which to save the key (/home/username/.ssh/id_rsa):
|
||||
Created directory '/home/username/.ssh'.
|
||||
Enter passphrase (empty for no passphrase):
|
||||
Enter same passphrase again:
|
||||
Your identification has been saved in /home/username/.ssh/id_rsa
|
||||
Your public key has been saved in /home/username/.ssh/id_rsa.pub
|
||||
The key fingerprint is:
|
||||
SHA256:nHcTP5DBKxBOgt8BFMyb2QUs//t8ge+8vw2zjOuE71U username@clientpc
|
||||
The key's randomart image is:
|
||||
+---[RSA 3072]----+
|
||||
| ==++o .. |
|
||||
| . +++ . .o |
|
||||
| . O.+ +. |
|
||||
| =.+.. .+ |
|
||||
| S...o.o E|
|
||||
| ..o...o |
|
||||
| ....+. |
|
||||
| .+ =o+.|
|
||||
| +O+*++|
|
||||
+----[SHA256]-----+
|
||||
}}}
|
||||
That's all you need to do. You now have a personal SSH key pair on your client computer.
|
||||
|
||||
==== Verify your FreedomBox Server Fingerprint ====
|
||||
On your first time connecting to !FreedomBox using ssh you may have noticed a message similar to this:
|
||||
{{{
|
||||
$ ssh fbx@freedombox.local
|
||||
The authenticity of host 'freedombox.local (192.168.1.4)' can't be established.
|
||||
ED25519 key fingerprint is SHA256:TwJFdepq7OaTXcycoYfYE8/lRtuOxUGCrst0K/RUh4E.
|
||||
This key is not known by any other names.
|
||||
Are you sure you want to continue connecting (yes/no/[fingerprint])?
|
||||
}}}
|
||||
There are a few things to understand about this message:
|
||||
* SSH is telling you that you have never connected to this server before and SSH cannot guarantee that this server is safe for you to use.
|
||||
* You have an opportunity to tell SSH that this new server is known to you and safe to use by indicating, 'yes.'
|
||||
* SSH has received an encryption key to communicate securely with this server (even if we're not certain which with server we're communicating with).
|
||||
* SSH is giving you a piece of information that you will use to confirm that the remote SSH server is in fact your !FreedomBox.
|
||||
|
||||
Go to !FreedomBox in your web browser. Click on the System menu, and then Secure Shell. The second section of this page is, Server Fingerprints. There is an ED25519 key entry on this page:
|
||||
||'''Algorithm'''||'''Fingerprint'''||
|
||||
||RSA||SHA256:ZGvgdxiDEpGKdw82Z6z0QRmDpT3Vgi07Ghba5IBJ4tQ||
|
||||
||ECDSA||SHA256:BLMMfPxNHpHF0sqCazAwE6ONdLtMY+W2yrgjP7AeXcQ||
|
||||
||ED25519||SHA256:TwJFdepq7OaTXcycoYfYE8/lRtuOxUGCrst0K/RUh4E||
|
||||
Compare the ED25519 fingerprint on the !FreedomBox Secure Shell page with the ED25519 fingerprint received by the ssh client in the first-connection example above. If these fingerprints are the same then you may be confident that you are connecting to your !FreedomBox.
|
||||
|
||||
If you'd like to walk through these steps but you have already made the first connection, you can reset that. Issue this command on your ssh client computer.
|
||||
{{{
|
||||
$ ssh-keygen -R freedombox.local
|
||||
}}}
|
||||
This removes the record of your known connection to !FreedomBox. Now open your Secure Shell system configuration page on !FreedomBox to the Server Fingerprints section. Next connect to !FreedomBox with your ssh client and properly verify the server fingerprint before indicating yes to the host authenticity question. Having done this correctly you can be certain that when you make an SSH connection to !FreedomBox you are connecting to your server.
|
||||
|
||||
Each time you connect to a new SSH server you will be given the opportunity to verify the server fingerprint. If you connect to !FreedomBox using different names or IP address (local IP, DNS name, Pagekite name, TOR .onion address...) you will be asked once for each name or address, but the fingerprint will not change.
|
||||
|
||||
Your server fingerprints are not private information. The fingerprint is a summary of a public key shared by the server which is used encrypt information sent to the SSH server. Your server public key is also not private information. You could share fingerprints and public keys with the world and the security of your !FreedomBox will not be diminished.
|
||||
|
||||
==== Share your personal SSH key with FreedomBox using ssh-copy-id ====
|
||||
By now you have a personal key pair, and you have verified the identity of !FreedomBox. !FreedomBox still does not know about your identity, and will ask you for your password when you try to log in by ssh. The ssh-copy-id program will tell !FreedomBox to accept your personal key as your password.
|
||||
{{{
|
||||
$ ssh-copy-id username@freedombox.local
|
||||
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
|
||||
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
|
||||
username@freedombox.local's password:
|
||||
|
||||
Number of key(s) added: 1
|
||||
|
||||
Now try logging into the machine, with: "ssh 'username@freedombox.local'"
|
||||
and check to make sure that only the key(s) you wanted were added.
|
||||
}}}
|
||||
This step adds your personal public key to your user account on !FreedomBox. With this step complete the !FreedomBox SSH server will compare the key sent by the client computer with the key stored on !FreedomBox. If these match then you will be logged in without the need to give a password. Try it now:
|
||||
{{{
|
||||
$ ssh freedombox.local
|
||||
Linux freedombox 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64
|
||||
|
||||
.--._ _.--.
|
||||
( \ / )
|
||||
\ /\ /
|
||||
\_ \/ _/
|
||||
/ \
|
||||
( /\ )
|
||||
`--' `--'
|
||||
|
||||
FreedomBox
|
||||
|
||||
FreedomBox is a pure blend of Debian GNU/Linux. Web interface is available at
|
||||
https://localhost/ . FreedomBox manual is available in /usr/share/doc/freedombox
|
||||
and from the web interface.
|
||||
|
||||
The programs included with the Debian GNU/Linux system are free software;
|
||||
the exact distribution terms for each program are described in the
|
||||
individual files in /usr/share/doc/*/copyright.
|
||||
|
||||
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
||||
permitted by applicable law.
|
||||
You have new mail.
|
||||
Last login: Sun Mar 17 14:27:03 2024 from 192.168.144.101
|
||||
username@freedombox:~$
|
||||
}}}
|
||||
|
||||
Once you have added your client SSH key to !FreedomBox you will be able to connect using that one key by every method of addressing your !FreedomBox:
|
||||
* Local network name
|
||||
* Local network IP address
|
||||
* ISP Public IP address
|
||||
* DNS name if you are using Dynamic DNS
|
||||
* Pagekite name if you are using Pagekite
|
||||
* TOR .onion address if you are using TOR
|
||||
|
||||
==== Block SSH password guessing attempts by disabling password authentication ====
|
||||
Once you are able to connect to !FreedomBox by ssh using a key and not entering a password you can take a step to improve the security of !FreedomBox. If your !FreedomBox is accessible from the internet you may notice that there are repeated attempts to log in to your !FreedomBox from the internet. A good password is your first line of defense, and !FreedomBox has additional features which protect you from these intrusion attempts. You can stop this nonsense completely by disabling password authentication for Secure Shell.
|
||||
|
||||
Go to your !FreedomBox System menu. Click the Secure Shell configuration link. Look under '''Configuration''' and select, "Disable password authentication"
|
||||
[x] Disable password authentication
|
||||
|
||||
Click the, "Update setup," button and it's done. This will stop all password guessing intrusion attempts using ssh. You can log in using your key, and nobody else will be able to log in by guessing a password.
|
||||
|
||||
=== External links ===
|
||||
|
||||
* Debian SSH wiki: https://wiki.debian.org/SSH
|
||||
* Upstream project: https://www.openssh.com
|
||||
* User documentation: https://www.openssh.com/manual.html
|
||||
* PuTTY Client Software: https://www.chiark.greenend.org.uk/~sgtatham/putty/
|
||||
|
||||
|
||||
## END_INCLUDE
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
!FreedomBox is a [[DebianPureBlends|pure blend]] of Debian. This means that all the work on !FreedomBox is available in Debian as packages. It also means that any machine running Debian can be turned into a !FreedomBox.
|
||||
|
||||
This page describes the process of installing !FreedomBox on a Debian system. Currently, !FreedomBox works in Debian Stable (Bullseye), Testing (Bookworm), and Unstable (Sid).
|
||||
This page describes the process of installing !FreedomBox on a Debian system. Currently, !FreedomBox works in Debian Stable (bookworm), Testing (trixie), and Unstable (sid).
|
||||
|
||||
'''Important:''' Read [[FreedomBox/Hardware|general advice]] about hardware before building a !FreedomBox with this approach.
|
||||
|
||||
@ -29,11 +29,11 @@ Installing !FreedomBox changes your Debian system in many important ways. This
|
||||
After !FreedomBox is fully setup, your system will no longer allow users not belonging to the ''admin'' group to log in to the system via console, secure shell (SSH) or graphical login. This behaviour can be disabled from the [[FreedomBox/Manual/Security|Security]] page. Use the administrator account created during !FreedomBox first boot for console logins and add further user accounts to ''admin'' group, if necessary.
|
||||
}}}
|
||||
|
||||
=== Installing on Debian 11 (Bullseye) or newer ===
|
||||
=== Installing on Debian 12 (bookworm) or newer ===
|
||||
|
||||
Check the Troubleshooting section below, for any tips or workarounds that might help during the install.
|
||||
|
||||
1. [[InstallingDebianOn|Install Debian]] 11 (Bullseye), or Unstable (Sid) on your hardware.
|
||||
1. [[InstallingDebianOn|Install Debian]] 12 (bookworm), or Unstable (sid) on your hardware.
|
||||
|
||||
1. Update your package list.
|
||||
|
||||
|
||||
@ -8,6 +8,81 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
|
||||
|
||||
The following are the release notes for each !FreedomBox version.
|
||||
|
||||
== FreedomBox 24.7 (2024-03-25) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* package: Don't remove packages of other apps on uninstall
|
||||
* samba: Fix Samba not accessible from IPv6 localhost ::1 address
|
||||
* system: Organize items into sections
|
||||
* users: Add email address field when creating/updating user accounts
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* actions: Minor refactor to action error logging
|
||||
* actions: Move most of the privileged action code to main directory
|
||||
* actions: Provide HTML error message with action error
|
||||
* backups: Adjust to changes in privileged errors
|
||||
* letsencrypt: Remove unnecessary processing of the error messages
|
||||
* letsencrypt: Show better error messages
|
||||
* letsencrypt: Simplify error warning when certificate revoke fails
|
||||
* matrixsynapse: Prevent setup page from being shown during uninstall
|
||||
* middleware: Show HTML exception message as extra detail in messages
|
||||
* package: Drop special error message handling for package errors
|
||||
* samba: Disable nmbd NetBIOS service
|
||||
* snapshot: Show better error messages
|
||||
* storage: Adjust to changes in privileged errors
|
||||
* storage: Show better error message
|
||||
* tests: Automatically create pytest marks for apps
|
||||
* tests: Merge actions related test files
|
||||
* tests: Move test configuration to plinth directory
|
||||
* tests: Remove unused fixture for testing actions
|
||||
* upgrades: Show better error messages
|
||||
* users: Add email address field during first boot
|
||||
* views: Fix alignment of close button in error messages
|
||||
* views: Implement a utility to easily show error message
|
||||
|
||||
== FreedomBox 24.6 (2024-03-11) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* gitweb: Fix modifying git repositories when gitweb app is disabled
|
||||
* users: Fix creating users with initial set of groups
|
||||
* wordpress:
|
||||
* Don't fail setup if mysql installed but not running
|
||||
* Drop database user when app is uninstalled
|
||||
* Fix backup, restore and uninstall when db is not running
|
||||
* zoph:
|
||||
* Don't fail setup if mysql installed but not running
|
||||
* Don't fail with backup/restore if app is disabled
|
||||
* Don't redirect to setup page when app is disabled
|
||||
* Ensure that database server is running when setting up app
|
||||
* Hide configuration form when app is disabled
|
||||
* Restore database password to old value after restore operation
|
||||
* Uninstall fully so that reinstall works
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* *: Add type hints for app init methods
|
||||
* *: Add type hints for diagnose method
|
||||
* action_utils: Implement method for starting a service temporarily
|
||||
* actions: Fix log message when action return can't be decoded
|
||||
* actions: When action errors out, log a better message
|
||||
* app: Add ability to hide configuration form when app is disabled
|
||||
* app: views: Expose method to get enabled/disabled state and cache it
|
||||
* daemon: Added method to ensure a daemon is running in component
|
||||
* diagnostics: Add tests for get_results
|
||||
* diagnostics: Handle !TypeError when copying results
|
||||
* locale: Update translations for Dutch
|
||||
* log: Don't log with in color inside actions scripts
|
||||
* samba: Ignore non-existent users who are in freedombox-share group
|
||||
* tests: functional: Refactor install/setup fixture for apps
|
||||
* tests: functional: Uninstall app after backup and before restore
|
||||
* users: Minor refactor when creating django groups
|
||||
* users: tests: Do not remove LDAP user when testing views
|
||||
* wordpress: Fix minor issue in restoring database
|
||||
* wordpress: tests: Uninstall app after backup and before restore
|
||||
|
||||
== FreedomBox 24.5 (2024-02-26) ==
|
||||
|
||||
* backups: tests: Don't use pytest marks on fixtures
|
||||
|
||||
@ -38,8 +38,39 @@ Hay un script incluído en el programa `freedom-maker` que permite establecer la
|
||||
El usuario "fbx" también tiene privilegios de superusuario mediante ``sudo``.
|
||||
|
||||
=== Ingresando ===
|
||||
|
||||
==== ¿Quién puede ingresar a FreedomBox por SSH? ====
|
||||
|
||||
==== Local ====
|
||||
Los usuarios administradores de !FreedomBox pueden usar SSH para ingresar. !FreedomBox crea el superusuario 'fbx'. Hay opciones que permiten ingresar a usuarios normales:
|
||||
* Se puede otorgar individualmente permiso de acceso por SSH a usuarios concretos en la página Editar Usuario seleccionando la opción ''Ingreso remoto usando Secure Shell (SSH) (freedombox-ssh)''.
|
||||
* Se puede otorgar permiso de acceso por SSH en masa a todos los usuarios en la página de configuración de SSH seleccionando la opción ''Permitir el ingreso remoto por SSH a todos los usuarios''.
|
||||
|
||||
En una !FreedomBox nueva puedes ingresar con SSH como fbx y los demás usuarios normales podrán hacerlo tras ajustar sus cuentas o la configuración de la Shell Segura arriba en esta sección.
|
||||
La cuenta de usuario root no podrá ingresar al no tener contraseña.
|
||||
|
||||
==== Software Cliente SSH ====
|
||||
|
||||
Muchos sistemas operativos, incluyendo Linux, Windows de Microsoft y MacOS de Apple incluyen clientes SSH. SSH se incluye en ''Chromebooks'' pero requiere que el usuario lo configure.
|
||||
En la mayoría de los casos puedes ejecutar SSH desde la terminal o línea de órdenes como se muestra aquí, usando el nombre de la máquina de !FreedomBox o su dirección IP:
|
||||
{{{
|
||||
$ ssh freedombox.local
|
||||
}}}
|
||||
|
||||
Si tu cliente no tiene SSH disponible, PuTTY es un cliente SSH popular y es software libre conforme a las Directrices de Debian para Software Libre.
|
||||
PuTTY tiene una interfaz gráfica para recordar y administrar tus conexiones SSH. Consulta los enlaces externos más abajo para amplisr información acerca de PuTTy.
|
||||
|
||||
===== Cockpit como alternativa a SSH =====
|
||||
|
||||
La aplicación ''Terminal de Administración de Servidor Cockpit'' disponible en el menú de ''Herramientas Cockpit'' es una herramienta de acceso por terminal alternativa a SSH.
|
||||
Como con SSH, su conexión al terminal !FreedomBox esta securizada. Cockpit es una buena opción para usuarios que no quieran habilitar el servidor SSH o que prefieran conectar mediante un navegador web.
|
||||
Com ambas herramientas se te presentará el interfaz de línea de órdenes bash de !FreedomBox.
|
||||
|
||||
Algunos usuarios prefieren ejecutar SSH en vez de o junto a Cockpit. Los usuarios de la consola de órdenes suelen preferir SSH porque ya lo usan.
|
||||
Los usuarios con experiencia administrando sistemas Linux o Unix suelen preferir este método de conexión porque es un servicio más simple y se cree que es más propenso a permanecer disponible en caso de problemas.
|
||||
|
||||
Para configurar Cockpit y los certificados SSL con seguridad lea las secciones del manual ''Cockpit'' y ''Let's Encrypt'', respectivamente.
|
||||
|
||||
==== SSL en la Red Local ====
|
||||
|
||||
Para ingresar mediante SSH a tu !FreedomBox:
|
||||
|
||||
@ -114,11 +145,186 @@ $ passwd
|
||||
|
||||
Esto te preguntará tu contraseña actual antes de darte la oportunidad de establecer la nueva.
|
||||
|
||||
=== Claves SSH ===
|
||||
|
||||
El siguiente paso para mejorar la seguridad y la comodidad es comprender y empleare las claves SSH. Si la primera vez ingresaste a tu !FreedomBox mediante SSH siguiendo las instrucciones anteriores diste un usuario y una contraseña.
|
||||
In esta sección aprenderás acerca de huellas de servidor, claves de máquina, claves autorizadas, y los motivos para usarlas securizando la conexión a la vez que la facilitas.
|
||||
|
||||
SSH está configurada por omisión para preferir ingresar con claves mientras te sigue permitiendo emplear un nombre de usuario y contraseña. Al final de esta sección podrás:
|
||||
* Conectar a !FreedomBox sabiendo que te conectas al ordenador deseado.
|
||||
* Conectar instantáneamente sin tener que dar usuario y contraseña.
|
||||
* Mejorar la seguridad de tu !FreedomBox deshabilitando la autenticación a SSH mediante cotraseña.
|
||||
|
||||
==== Claves SSH Públicas y Privadas ====
|
||||
|
||||
Las claves SSH se generan emparejadas. Cada par par consta de una clave pública y su clave privada correspondiente.
|
||||
Cada clave cifra los datos de modo que solo se pueden leer con la otra: lo que cifra la privada solo lo descifra la pública y viceversa.
|
||||
Esto se llama sistema de cifrado asimétrico. SSH mantendrá tus claves privadas seguras y comunicará automáticamente tus claves públicas al otro sistema.
|
||||
|
||||
Empplear claves SSH crea un conjunto potente de características de seguridad:
|
||||
* Te aseguran que te conectas a tu !FreedomBox (y no a un impostor).
|
||||
* Nadie más podrá leer ni modificar tu comunicación con !FreedomBox.
|
||||
* El servicio SSH de !FreedomBox SSH sabrá que eres tú (y no un impostor) el usuario conectado.
|
||||
* Nadie más podrá leer ni modificar la comunicación de !FreedomBox destinada a tí.
|
||||
* La conexión es automática sin nombre de usuario ni contraseña.
|
||||
* Tu !FreedomBox puede bloquear cualquier ataque basado en adivinar tu contraseña.
|
||||
|
||||
==== Crea tus claves SSH personales en tu ordenador cliente usando ssh-keygen ====
|
||||
|
||||
Crearemos un par de claves SSH tu ordenador cliente usando usando valores por omisión y sin dar una contraseña.
|
||||
Usa el comando {{{ssh-keygen}}} sin argumentos y cuando se te pida una contraseña introdúcela vacía.
|
||||
He aquí un ejemplo:
|
||||
|
||||
{{{
|
||||
$ ssh-keygen
|
||||
Generating public/private rsa key pair.
|
||||
Enter file in which to save the key (/home/username/.ssh/id_rsa):
|
||||
Created directory '/home/username/.ssh'.
|
||||
Enter passphrase (empty for no passphrase):
|
||||
Enter same passphrase again:
|
||||
Your identification has been saved in /home/username/.ssh/id_rsa
|
||||
Your public key has been saved in /home/username/.ssh/id_rsa.pub
|
||||
The key fingerprint is:
|
||||
SHA256:nHcTP5DBKxBOgt8BFMyb2QUs//t8ge+8vw2zjOuE71U username@clientpc
|
||||
The key's randomart image is:
|
||||
+---[RSA 3072]----+
|
||||
| ==++o .. |
|
||||
| . +++ . .o |
|
||||
| . O.+ +. |
|
||||
| =.+.. .+ |
|
||||
| S...o.o E|
|
||||
| ..o...o |
|
||||
| ....+. |
|
||||
| .+ =o+.|
|
||||
| +O+*++|
|
||||
+----[SHA256]-----+
|
||||
}}}
|
||||
|
||||
Ya tienes un par de claves SSH personales en tu ordenador cliente.
|
||||
|
||||
==== Verificar la Huella de tu Servidor FreedomBox ====
|
||||
|
||||
La primera vez que te conectes a !FreedomBox se te presentará un mensaje como este:
|
||||
|
||||
{{{
|
||||
$ ssh fbx@freedombox.local
|
||||
No se puede asegurar la autenticidad de 'freedombox.local (192.168.1.4)'.
|
||||
Su huella ED25519 es SHA256:TwJFdepq7OaTXcycoYfYE8/lRtuOxUGCrst0K/RUh4E.
|
||||
Esta huella no consta asociada a ningún otro nombre.
|
||||
¿Seguro que quiere conectar (Si/No/[huella])?
|
||||
}}}
|
||||
|
||||
Hay varias partes que hay que entender en este mensaje:
|
||||
* SSH te dice que nunca antes has conectado con este servidor por lo que no puede garantizarte que sea seguro.
|
||||
* SSH te ofrece la oportunidad de validar este nuevo servidor indicando 'Si'.
|
||||
* SSH ha recibido una clave de cifrado para comunicar con seguridad con este servidor (aunque no tengamos certeza de a qué máquina nos conectamos).
|
||||
* SSH te está dando información que usarás para confirmar que el servidor SSH remoto es tu !FreedomBox.
|
||||
|
||||
Vé con tu navegador a !FreedomBox. Entra en el menú de Sistema y luego a Shell Segura. La segunda sección de esta página es Huellas de Servidor y tiene una entrada ED25519:
|
||||
||'''Algoritmo'''||'''Huella'''||
|
||||
||RSA||SHA256:ZGvgdxiDEpGKdw82Z6z0QRmDpT3Vgi07Ghba5IBJ4tQ||
|
||||
||ECDSA||SHA256:BLMMfPxNHpHF0sqCazAwE6ONdLtMY+W2yrgjP7AeXcQ||
|
||||
||ED25519||SHA256:TwJFdepq7OaTXcycoYfYE8/lRtuOxUGCrst0K/RUh4E||
|
||||
Compara la huella ED25519 de la página Shell Segura de tu !FreedomBox con la que ha recibido tu cliente SSH en su primera conexión. Si las huellas coinciden puedes confiar que estás conectando con tu !FreedomBox.
|
||||
|
||||
Me gustaría acompañarte a dar estos pasos pero ... ¿ya has realizado tu primera conexión? Puedes reiniciar el proceso con esta orden en el ordenador de tu cliente SSH.
|
||||
{{{
|
||||
$ ssh-keygen -R freedombox.local
|
||||
}}}
|
||||
Esto borra el registro de tu conexión a !FreedomBox. Ahora abre la página de configuración de la Shell Segura en !FreedomBox por la sección de Huellas de Servidor.
|
||||
A continuación conecta a !FreedomBox con tu cliente SSH y verifica la huella de servidor antes de responder afirmativamente a la pregunta de authenticidad de la máquina.
|
||||
Hacer esto correctamente te garantiza que cuando conectes mediante SSH a !FreedomBox te conectas al tuyo (y no a otro).
|
||||
|
||||
Cada vez que te conectes a un servidor SSH nuevo para tí se te dará la oportunidad de verificar su huella.
|
||||
Si te conectas a !FreedomBox usando nombres o direcciones IP diferentes (IP local, nombre DNS, nombre Pagekite, dirección .onion para TOR...) se te preguntará una vez por
|
||||
cada una pero la huella será siempre la misma.
|
||||
|
||||
Las huellas de tu servidor no son secretas. La huella es una versión resumida de la clave pública que se comparte para que la usen para cifrar la comunicación que se te envía.
|
||||
Tu clave pública tampoco es secreta. Podrías publicar las huellas y las claves públicas sin afectar un ápice a la seguridad de tu !FreedomBox.
|
||||
|
||||
==== Comparte tu clave SSH personal pública con FreedomBox usando ssh-copy-id ====
|
||||
|
||||
Ahora que tienes una clave personal y has verificado la identidad de !FreedomBox, éste sigue sin conocer la tuya y te pedirá una contraseña al intentar ingresar mediante SSH.
|
||||
La orden {{{ssh-copy-id}}} le dirá a !FreedomBox que acepte tu clave personal en vez de tu contraseña.
|
||||
|
||||
{{{
|
||||
$ ssh-copy-id username@freedombox.local
|
||||
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
|
||||
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
|
||||
username@freedombox.local's password:
|
||||
|
||||
Number of key(s) added: 1
|
||||
|
||||
Now try logging into the machine, with: "ssh 'username@freedombox.local'"
|
||||
and check to make sure that only the key(s) you wanted were added.
|
||||
}}}
|
||||
|
||||
Estos pasos emparejan tu clave personal pública a tu cuenta de usuario en !FreedomBox. Al completar este paso el servidor SSH de !FreedomBox comparará la clave que le envía el
|
||||
ordenador cliente con la que ha guardado !FreedomBox. Si coinciden ingresarás sin necesidad de introducir una contraseña. Compruébalo ahora:
|
||||
|
||||
{{{
|
||||
$ ssh freedombox.local
|
||||
Linux freedombox 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64
|
||||
|
||||
.--._ _.--.
|
||||
( \ / )
|
||||
\ /\ /
|
||||
\_ \/ _/
|
||||
/ \
|
||||
( /\ )
|
||||
`--' `--'
|
||||
|
||||
FreedomBox
|
||||
|
||||
FreedomBox is a pure blend of Debian GNU/Linux. Web interface is available at
|
||||
https://localhost/ . FreedomBox manual is available in /usr/share/doc/freedombox
|
||||
and from the web interface.
|
||||
|
||||
The programs included with the Debian GNU/Linux system are free software;
|
||||
the exact distribution terms for each program are described in the
|
||||
individual files in /usr/share/doc/*/copyright.
|
||||
|
||||
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
||||
permitted by applicable law.
|
||||
You have new mail.
|
||||
Last login: Sun Mar 17 14:27:03 2024 from 192.168.144.101
|
||||
username@freedombox:~$
|
||||
}}}
|
||||
|
||||
Una vez !FreedomBox conoce tu clave pública podrás conectar usándola sin importar cómo te refieras a !FreedomBox:
|
||||
* Nombre en la red local
|
||||
* Dirección IP en la red local
|
||||
* Dirección IP pública de tu proveedor de internet
|
||||
* Nombre DNS, si usas DNS dinámico
|
||||
* Nombre Pagekite, si usas Pagekite
|
||||
* Dirección .onion, si usas TOR
|
||||
|
||||
==== Bloquear intentos de adivinar tu contraseña SSH deshabilitando la autenticación mediante contraseña ====
|
||||
|
||||
Cuando ya puedas conectar a !FreedomBox por SSH mediante clave SSH sin introducir contraseña puedes dar otro paso para mejorar la seguridad de !FreedomBox.
|
||||
Si tienes tu !FreedomBox accesible desde internet quizá notes que se repiten intentos de ingreso desde internet. Una buena contraseña es tu primera linea de defensa,
|
||||
pero !FreedomBox tiene más características para protegerte de estos intentos de intrusión. Puedes atajar por completo este disparate deshabilitando la autenticación
|
||||
por contraseña para la Shell Segura.
|
||||
En el menú de ''Sistema'' de tu !FreedomBox elige la ''Configuración de Shell Segura'' y debajo de ''Configuración'' selecciona "Deshabilitar autenticación por contraseña":
|
||||
|
||||
[x] Deshabilitar autenticación por contraseña
|
||||
|
||||
Dale al botón "Actualizar Ajustes". Esto impide cualquier intento de intrusión que quiera adivinar tu contraseña. Podrás ingresar desde este ordenador cliente con tu clave.
|
||||
|
||||
===== Conculsión acerca de la Huella de Servidor =====
|
||||
|
||||
En esta sección hemos aprendido a encontrar las Huellas de Servidor de la Shell Segura de !FreedomBox.
|
||||
Hemos verificado la conexión con !FreedomBox comparando la huella recibida por el cliente SSH con la que hay en el servidor SSH de !FreedomBox.
|
||||
Estos pasos solo se necesitan la primera vez que conectamos con !FreedomBox.
|
||||
Quizá necesites repetirlos al conectar mediante la dirección IP o los nombres de la máquina en la red local o desde fuera de ella.
|
||||
En cada caso recibirás la misma Huella de Servidor que podrás verificar la primera vez.
|
||||
|
||||
=== Enlaces externos ===
|
||||
|
||||
* Proyecto original: https://www.openssh.com
|
||||
* Documentación de uso: https://www.openssh.com/manual.html
|
||||
* Software cliente PuTTY : https://www.chiark.greenend.org.uk/~sgtatham/putty/
|
||||
* SSH en el wiki de Debian: https://wiki.debian.org/SSH
|
||||
|
||||
|
||||
## END_INCLUDE
|
||||
|
||||
@ -3,4 +3,4 @@
|
||||
Package init file.
|
||||
"""
|
||||
|
||||
__version__ = '24.5'
|
||||
__version__ = '24.7'
|
||||
|
||||
@ -48,6 +48,20 @@ def service_is_running(servicename):
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def service_ensure_running(service_name):
|
||||
"""Ensure a service is running and return to previous state."""
|
||||
starting_state = service_is_running(service_name)
|
||||
if not starting_state:
|
||||
service_enable(service_name)
|
||||
|
||||
try:
|
||||
yield starting_state
|
||||
finally:
|
||||
if not starting_state:
|
||||
service_disable(service_name)
|
||||
|
||||
|
||||
def service_is_enabled(service_name, strict_check=False):
|
||||
"""Check if service is enabled in systemd.
|
||||
|
||||
@ -275,7 +289,7 @@ def uwsgi_disable(config_name):
|
||||
service_start('uwsgi')
|
||||
|
||||
|
||||
def get_addresses():
|
||||
def get_addresses() -> list[dict[str, str | bool]]:
|
||||
"""Return a list of IP addresses and hostnames."""
|
||||
addresses = get_ip_addresses()
|
||||
|
||||
@ -309,14 +323,14 @@ def get_addresses():
|
||||
return addresses
|
||||
|
||||
|
||||
def get_ip_addresses():
|
||||
def get_ip_addresses() -> list[dict[str, str | bool]]:
|
||||
"""Return a list of IP addresses assigned to the system."""
|
||||
addresses = []
|
||||
|
||||
output = subprocess.check_output(['ip', '-o', 'addr'])
|
||||
for line in output.decode().splitlines():
|
||||
parts = line.split()
|
||||
address = {
|
||||
address: dict[str, str | bool] = {
|
||||
'kind': '4' if parts[2] == 'inet' else '6',
|
||||
'address': parts[3].split('/')[0],
|
||||
'url_address': parts[3].split('/')[0],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Framework to run specified actions with elevated privileges."""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import importlib
|
||||
import inspect
|
||||
@ -8,9 +9,16 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import types
|
||||
import typing
|
||||
|
||||
from plinth import cfg
|
||||
from plinth import cfg, log, module_loader
|
||||
|
||||
EXIT_SYNTAX = 10
|
||||
EXIT_PERM = 20
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -124,19 +132,18 @@ def _wait_for_return(module_name, action_name, args, kwargs, log_error, proc,
|
||||
"""Communicate with the subprocess and wait for its return."""
|
||||
json_args = json.dumps({'args': args, 'kwargs': kwargs})
|
||||
|
||||
output, error = proc.communicate(input=json_args.encode())
|
||||
stdout, stderr = proc.communicate(input=json_args.encode())
|
||||
read_thread.join()
|
||||
if proc.returncode != 0:
|
||||
logger.error('Error executing command - %s, %s, %s', command, output,
|
||||
error)
|
||||
logger.error('Error executing command - %s, %s, %s', command, stdout,
|
||||
stderr)
|
||||
raise subprocess.CalledProcessError(proc.returncode, command)
|
||||
|
||||
try:
|
||||
return_value = json.loads(b''.join(buffers))
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
'Error decoding action return value %s..%s(*%s, **%s): %s',
|
||||
module_name, action_name, args, kwargs, return_value)
|
||||
logger.error('Error decoding action return value %s..%s(*%s, **%s)',
|
||||
module_name, action_name, args, kwargs)
|
||||
raise
|
||||
|
||||
if return_value['result'] == 'success':
|
||||
@ -144,16 +151,68 @@ def _wait_for_return(module_name, action_name, args, kwargs, log_error, proc,
|
||||
|
||||
module = importlib.import_module(return_value['exception']['module'])
|
||||
exception_class = getattr(module, return_value['exception']['name'])
|
||||
exception = exception_class(*return_value['exception']['args'], output,
|
||||
error)
|
||||
exception = exception_class(*return_value['exception']['args'])
|
||||
exception.stdout = stdout
|
||||
exception.stderr = stderr
|
||||
|
||||
def _get_html_message():
|
||||
"""Return an HTML format error that can be shown in messages."""
|
||||
from django.utils.html import format_html
|
||||
|
||||
full_args, exception_args, stdout, stderr, traceback = _format_error(
|
||||
args, kwargs, exception, return_value)
|
||||
return format_html('Error running action: {}..{}({}): {}({})\n{}{}{}',
|
||||
module_name, action_name, full_args,
|
||||
return_value['exception']['name'], exception_args,
|
||||
stdout, stderr, traceback)
|
||||
|
||||
exception.get_html_message = _get_html_message
|
||||
|
||||
if log_error:
|
||||
logger.error('Error running action %s..%s(*%s, **%s): %s %s %s',
|
||||
module_name, action_name, args, kwargs, exception,
|
||||
exception.args, return_value['exception']['traceback'])
|
||||
full_args, exception_args, stdout, stderr, traceback = _format_error(
|
||||
args, kwargs, exception, return_value)
|
||||
logger.error('Error running action %s..%s(%s): %s(%s)\n'
|
||||
'%s%s%s', module_name, action_name, full_args,
|
||||
return_value['exception']['name'], exception_args, stdout,
|
||||
stderr, traceback)
|
||||
|
||||
raise exception
|
||||
|
||||
|
||||
def _format_error(args, kwargs, exception, return_value):
|
||||
"""Log the exception in a readable manner."""
|
||||
args = [json.dumps(arg) for arg in args]
|
||||
kwargs = [f'{key}=' + json.dumps(value) for key, value in kwargs.items()]
|
||||
full_args = ', '.join(args + kwargs)
|
||||
exception_args = ', '.join([json.dumps(arg) for arg in exception.args])
|
||||
|
||||
stdout = exception.stdout.decode()
|
||||
if stdout:
|
||||
lines = stdout.split('\n')
|
||||
lines = lines[:-1] if not lines[-1] else lines
|
||||
stdout = '\n'.join(('│ ' + line for line in lines))
|
||||
stdout = 'Stdout:\n' + stdout + '\n'
|
||||
|
||||
stderr = exception.stderr.decode()
|
||||
if stderr:
|
||||
lines = stderr.split('\n')
|
||||
lines = lines[:-1] if not lines[-1] else lines
|
||||
stderr = '\n'.join(('║ ' + line for line in lines))
|
||||
stderr = 'Stderr:\n' + stderr + '\n'
|
||||
|
||||
traceback = return_value['exception']['traceback']
|
||||
if traceback:
|
||||
all_lines = []
|
||||
for entry in traceback:
|
||||
lines = entry.split('\n')
|
||||
all_lines += lines[:-1] if not lines[-1] else lines
|
||||
|
||||
traceback = '\n'.join(('╞ ' + line for line in all_lines))
|
||||
traceback = 'Action traceback:\n' + traceback + '\n'
|
||||
|
||||
return (full_args, exception_args, stdout, stderr, traceback)
|
||||
|
||||
|
||||
def _thread_reader(read_fd, buffers):
|
||||
"""Read from the pipe in a separate thread."""
|
||||
while True:
|
||||
@ -198,3 +257,189 @@ def _log_action(module_name, action_name, run_as_user, run_in_background):
|
||||
prompt = f'({run_as_user})$' if run_as_user else '#'
|
||||
suffix = '&' if run_in_background else ''
|
||||
logger.info('%s %s..%s(…) %s', prompt, module_name, action_name, suffix)
|
||||
|
||||
|
||||
def privileged_main():
|
||||
"""Parse arguments for the program spawned as a privileged action."""
|
||||
log.action_init()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('module', help='Module to trigger action in')
|
||||
parser.add_argument('action', help='Action to trigger in module')
|
||||
parser.add_argument('--write-fd', type=int, default=1,
|
||||
help='File descriptor to write output to')
|
||||
parser.add_argument('--no-args', default=False, action='store_true',
|
||||
help='Do not read arguments from stdin')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
try:
|
||||
arguments = {'args': [], 'kwargs': {}}
|
||||
if not args.no_args:
|
||||
input_ = sys.stdin.read()
|
||||
if input_:
|
||||
arguments = json.loads(input_)
|
||||
except json.JSONDecodeError as exception:
|
||||
raise SyntaxError('Arguments on stdin not JSON.') from exception
|
||||
|
||||
return_value = _privileged_call(args.module, args.action, arguments)
|
||||
with os.fdopen(args.write_fd, 'w') as write_file_handle:
|
||||
write_file_handle.write(json.dumps(return_value))
|
||||
except PermissionError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_PERM)
|
||||
except SyntaxError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_SYNTAX)
|
||||
except TypeError as exception:
|
||||
logger.error(exception.args[0])
|
||||
sys.exit(EXIT_SYNTAX)
|
||||
except Exception as exception:
|
||||
logger.exception(exception)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _privileged_call(module_name, action_name, arguments):
|
||||
"""Import the module and run action as superuser"""
|
||||
if '.' in module_name:
|
||||
raise SyntaxError('Invalid module name')
|
||||
|
||||
cfg.read()
|
||||
if module_name == 'plinth':
|
||||
import_path = 'plinth'
|
||||
else:
|
||||
import_path = module_loader.get_module_import_path(module_name)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(import_path + '.privileged')
|
||||
except ModuleNotFoundError as exception:
|
||||
raise SyntaxError('Specified module not found') from exception
|
||||
|
||||
try:
|
||||
action = getattr(module, action_name)
|
||||
except AttributeError as exception:
|
||||
raise SyntaxError('Specified action not found') from exception
|
||||
|
||||
if not getattr(action, '_privileged', None):
|
||||
raise SyntaxError('Specified action is not privileged action')
|
||||
|
||||
func = getattr(action, '__wrapped__')
|
||||
|
||||
_privileged_assert_valid_arguments(func, arguments)
|
||||
|
||||
try:
|
||||
return_values = func(*arguments['args'], **arguments['kwargs'])
|
||||
return_value = {'result': 'success', 'return': return_values}
|
||||
except Exception as exception:
|
||||
logger.exception('Error executing action: %s', exception)
|
||||
return_value = {
|
||||
'result': 'exception',
|
||||
'exception': {
|
||||
'module': type(exception).__module__,
|
||||
'name': type(exception).__name__,
|
||||
'args': exception.args,
|
||||
'traceback': traceback.format_tb(exception.__traceback__)
|
||||
}
|
||||
}
|
||||
|
||||
return return_value
|
||||
|
||||
|
||||
def _privileged_assert_valid_arguments(func, arguments):
|
||||
"""Check the names, types and completeness of the arguments passed."""
|
||||
# Check if arguments match types
|
||||
if not isinstance(arguments, dict):
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
if 'args' not in arguments or 'kwargs' not in arguments:
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
args = arguments['args']
|
||||
kwargs = arguments['kwargs']
|
||||
if not isinstance(args, list) or not isinstance(kwargs, dict):
|
||||
raise SyntaxError('Invalid arguments format')
|
||||
|
||||
argspec = inspect.getfullargspec(func)
|
||||
if len(args) + len(kwargs) > len(argspec.args):
|
||||
raise SyntaxError('Too many arguments')
|
||||
|
||||
no_defaults = len(argspec.args)
|
||||
if argspec.defaults:
|
||||
no_defaults -= len(argspec.defaults)
|
||||
|
||||
for key in argspec.args[len(args):no_defaults]:
|
||||
if key not in kwargs:
|
||||
raise SyntaxError(f'Argument not provided: {key}')
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in argspec.args:
|
||||
raise SyntaxError(f'Unknown argument: {key}')
|
||||
|
||||
if argspec.args.index(key) < len(args):
|
||||
raise SyntaxError(f'Duplicate argument: {key}')
|
||||
|
||||
_privileged_assert_valid_type(f'arg {key}', value,
|
||||
argspec.annotations[key])
|
||||
|
||||
for index, arg in enumerate(args):
|
||||
annotation = argspec.annotations[argspec.args[index]]
|
||||
_privileged_assert_valid_type(f'arg #{index}', arg, annotation)
|
||||
|
||||
|
||||
def _privileged_assert_valid_type(arg_name, value, annotation):
|
||||
"""Assert that the type of argument value matches the annotation."""
|
||||
if annotation == typing.Any:
|
||||
return
|
||||
|
||||
NoneType = type(None)
|
||||
if annotation == NoneType:
|
||||
if value is not None:
|
||||
raise TypeError('Expected None for {arg_name}')
|
||||
|
||||
return
|
||||
|
||||
basic_types = {bool, int, str, float}
|
||||
if annotation in basic_types:
|
||||
if not isinstance(value, annotation):
|
||||
raise TypeError(
|
||||
f'Expected type {annotation.__name__} for {arg_name}')
|
||||
|
||||
return
|
||||
|
||||
# 'int | str' or 'typing.Union[int, str]'
|
||||
if (isinstance(annotation, types.UnionType)
|
||||
or getattr(annotation, '__origin__', None) == typing.Union):
|
||||
for arg in annotation.__args__:
|
||||
try:
|
||||
_privileged_assert_valid_type(arg_name, value, arg)
|
||||
return
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
raise TypeError(f'Expected one of unioned types for {arg_name}')
|
||||
|
||||
# 'list[int]' or 'typing.List[int]'
|
||||
if getattr(annotation, '__origin__', None) == list:
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f'Expected type list for {arg_name}')
|
||||
|
||||
for index, inner_item in enumerate(value):
|
||||
_privileged_assert_valid_type(f'{arg_name}[{index}]', inner_item,
|
||||
annotation.__args__[0])
|
||||
|
||||
return
|
||||
|
||||
# 'list[dict]' or 'typing.List[dict]'
|
||||
if getattr(annotation, '__origin__', None) == dict:
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f'Expected type dict for {arg_name}')
|
||||
|
||||
for inner_key, inner_value in value.items():
|
||||
_privileged_assert_valid_type(f'{arg_name}[{inner_key}]',
|
||||
inner_key, annotation.__args__[0])
|
||||
_privileged_assert_valid_type(f'{arg_name}[{inner_value}]',
|
||||
inner_value, annotation.__args__[1])
|
||||
|
||||
return
|
||||
|
||||
raise TypeError('Unsupported annotation type')
|
||||
|
||||
@ -7,9 +7,10 @@ import collections
|
||||
import enum
|
||||
import inspect
|
||||
import logging
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, TypeAlias
|
||||
|
||||
from plinth import cfg
|
||||
from plinth.diagnostic_check import DiagnosticCheck
|
||||
from plinth.signals import post_app_loading
|
||||
|
||||
from . import clients as clients_module
|
||||
@ -17,6 +18,8 @@ from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_list_type: TypeAlias = list
|
||||
|
||||
|
||||
class App:
|
||||
"""Implement common functionality for an app.
|
||||
@ -39,6 +42,10 @@ class App:
|
||||
the app. This flag is currently set during backup and restore operations
|
||||
but UI changes are currently not implemented.
|
||||
|
||||
'configure_when_disabled' is a boolean indicating whether the app can
|
||||
configured while it is disabled. Some apps such those whose configuration
|
||||
is stored in a database can't be configured while they are disabled because
|
||||
the database server may not be running when the app is disabled.
|
||||
"""
|
||||
|
||||
app_id: str | None = None
|
||||
@ -48,6 +55,8 @@ class App:
|
||||
locked: bool = False # Whether user interaction with the app is allowed.
|
||||
# XXX: Lockdown the application UI by implementing a middleware
|
||||
|
||||
configure_when_disabled: bool = True
|
||||
|
||||
_all_apps: ClassVar[collections.OrderedDict[
|
||||
str, 'App']] = collections.OrderedDict()
|
||||
|
||||
@ -58,7 +67,7 @@ class App:
|
||||
NEEDS_UPDATE = 'needs-update'
|
||||
UP_TO_DATE = 'up-to-date'
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Build the app by adding components.
|
||||
|
||||
App may be built just for the purpose for querying. For example, when
|
||||
@ -71,7 +80,7 @@ class App:
|
||||
if not self.app_id:
|
||||
raise ValueError('Invalid app ID configured')
|
||||
|
||||
self.components = collections.OrderedDict()
|
||||
self.components: dict[str, Component] = collections.OrderedDict()
|
||||
|
||||
# Add self to global list of apps
|
||||
self._all_apps[self.app_id] = self
|
||||
@ -208,11 +217,11 @@ class App:
|
||||
if not component.is_leader:
|
||||
component.set_enabled(enabled)
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> _list_type[DiagnosticCheck]:
|
||||
"""Run diagnostics and return results.
|
||||
|
||||
Return value must be a list of results. Each result is a
|
||||
:class:`~plinth.modules.diagnostics.check.DiagnosticCheck` with a
|
||||
:class:`~plinth.diagnostic_check.DiagnosticCheck` with a
|
||||
unique check_id, a user visible description of the test, and the
|
||||
result. The test result is a string enumeration from 'failed',
|
||||
'passed', 'error', 'warning' and 'not_done'.
|
||||
@ -300,12 +309,11 @@ class Component:
|
||||
def disable(self):
|
||||
"""Run operations to disable the component."""
|
||||
|
||||
@staticmethod
|
||||
def diagnose():
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return results.
|
||||
|
||||
Return value must be a list of results. Each result is a
|
||||
:class:`~plinth.modules.diagnostics.check.DiagnosticCheck` with a
|
||||
:class:`~plinth.diagnostic_check.DiagnosticCheck` with a
|
||||
unique check_id, a user visible description of the test, and the
|
||||
result. The test result is a string enumeration from 'failed',
|
||||
'passed', 'error', 'warning' and 'not_done'.
|
||||
|
||||
@ -5,6 +5,8 @@ import pathlib
|
||||
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
DiagnosticCheckParameters, Result)
|
||||
from plinth.privileged import config as privileged
|
||||
|
||||
from . import app as app_module
|
||||
@ -99,10 +101,8 @@ class DropinConfigs(app_module.FollowerComponent):
|
||||
for path in self.etc_paths:
|
||||
privileged.dropin_unlink(self.app_id, path, missing_ok=True)
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""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)
|
||||
@ -118,7 +118,7 @@ class DropinConfigs(app_module.FollowerComponent):
|
||||
result_string = Result.PASSED if result else Result.FAILED
|
||||
description = gettext_noop(
|
||||
'Static configuration {etc_path} is setup properly')
|
||||
parameters = {'etc_path': str(etc_path)}
|
||||
parameters: DiagnosticCheckParameters = {'etc_path': str(etc_path)}
|
||||
results.append(
|
||||
DiagnosticCheck(check_id, description, result_string,
|
||||
parameters))
|
||||
|
||||
252
plinth/conftest.py
Normal file
252
plinth/conftest.py
Normal file
@ -0,0 +1,252 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
pytest configuration for all tests.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
importlib.import_module('splinter')
|
||||
importlib.import_module('selenium')
|
||||
_functional_libs_available = True
|
||||
except ImportError:
|
||||
_functional_libs_available = False
|
||||
|
||||
|
||||
def pytest_ignore_collect(path, config):
|
||||
"""Ignore functional tests when splinter is not available."""
|
||||
if path.basename == 'test_functional.py':
|
||||
return not _functional_libs_available
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Register additional markers, one for each app."""
|
||||
for app in (pathlib.Path(__file__).parent / 'modules').iterdir():
|
||||
if not app.is_dir():
|
||||
continue
|
||||
|
||||
config.addinivalue_line('markers', app.name)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Filter out specificly marked tests unless explicitly requested.
|
||||
|
||||
The EXTENDED_TESTING environment variable is borrowed from the Lancaster
|
||||
consensus met by the Pearl community. See
|
||||
https://github.com/Perl-Toolchain-Gang/toolchain-site/blob/master/lancaster-consensus.md
|
||||
"""
|
||||
|
||||
def skip(item, reason):
|
||||
item.add_marker(pytest.mark.skip(reason=reason))
|
||||
|
||||
extended = 'EXTENDED_TESTING' in os.environ
|
||||
if not (extended or config.getoption('--include-functional')):
|
||||
for item in items:
|
||||
if 'functional' in item.keywords or (
|
||||
item.parent.fspath.basename
|
||||
and item.parent.fspath.basename == 'test_functional.py'):
|
||||
skip(item, '--include-functional not provided')
|
||||
|
||||
if not extended:
|
||||
for item in items:
|
||||
if 'heavy' in item.keywords:
|
||||
skip(item, ('Takes too much time. '
|
||||
'Set EXTENDED_TESTING=1 to force run'))
|
||||
|
||||
|
||||
@pytest.fixture(name='load_cfg')
|
||||
def fixture_load_cfg():
|
||||
"""Load test configuration."""
|
||||
from plinth import cfg
|
||||
|
||||
keys = ('file_root', 'config_dir', 'data_dir', 'custom_static_dir',
|
||||
'store_file', 'actions_dir', 'doc_dir', 'server_dir', 'host',
|
||||
'port', 'use_x_forwarded_for', 'use_x_forwarded_host',
|
||||
'secure_proxy_ssl_header', 'box_name', 'develop')
|
||||
saved_state = {}
|
||||
for key in keys:
|
||||
saved_state[key] = getattr(cfg, key)
|
||||
|
||||
root_dir = pathlib.Path(__file__).resolve().parent
|
||||
cfg_file = root_dir / 'plinth' / 'develop.config'
|
||||
cfg.read_file(str(cfg_file))
|
||||
yield cfg
|
||||
|
||||
for key in keys:
|
||||
setattr(cfg, key, saved_state[key])
|
||||
|
||||
|
||||
@pytest.fixture(name='develop_mode')
|
||||
def fixture_develop_mode(load_cfg):
|
||||
"""Turn on development mode for a test."""
|
||||
load_cfg.develop = True
|
||||
yield
|
||||
load_cfg.develop = False
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_root', scope='session')
|
||||
def fixture_needs_root():
|
||||
"""Skip test if not running in root mode."""
|
||||
if os.geteuid() != 0:
|
||||
pytest.skip('Needs to be root')
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_not_root', scope='session')
|
||||
def fixture_needs_not_root():
|
||||
"""Skip test if running in root mode."""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip('Needs not to be root')
|
||||
|
||||
|
||||
@pytest.fixture(name='needs_sudo')
|
||||
def fixture_needs_sudo():
|
||||
"""Skip test if sudo command is not available."""
|
||||
if not os.path.isfile('/usr/bin/sudo'):
|
||||
pytest.skip('Needs sudo command installed.')
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_selenium_implicit_wait():
|
||||
"""Disable implicit waiting."""
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_wait_time():
|
||||
"""Disable explicit waiting."""
|
||||
return 0.01
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def splinter_browser_load_condition():
|
||||
"""When a page it loaded, wait until <body> is available."""
|
||||
|
||||
def _load_condition(browser):
|
||||
if browser.url == 'about:blank':
|
||||
return True
|
||||
|
||||
ready_state = browser.execute_script('return document.readyState;')
|
||||
return ready_state == 'complete'
|
||||
|
||||
return _load_condition
|
||||
|
||||
|
||||
@pytest.fixture(name='mock_privileged')
|
||||
def fixture_mock_privileged(request):
|
||||
"""Mock the privileged decorator to nullify its effects."""
|
||||
try:
|
||||
privileged_modules_to_mock = request.module.privileged_modules_to_mock
|
||||
except AttributeError:
|
||||
raise AttributeError(
|
||||
'mock_privileged fixture requires "privileged_module_to_mock" '
|
||||
'attribute at module level')
|
||||
|
||||
for module_name in privileged_modules_to_mock:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, member in module.__dict__.items():
|
||||
wrapped = getattr(member, '__wrapped__', None)
|
||||
if not callable(member) or not wrapped:
|
||||
continue
|
||||
|
||||
if not getattr(member, '_privileged', False):
|
||||
continue
|
||||
|
||||
setattr(wrapped, '_original_wrapper', member)
|
||||
module.__dict__[name] = wrapped
|
||||
|
||||
yield
|
||||
|
||||
for module_name in privileged_modules_to_mock:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, member in module.__dict__.items():
|
||||
wrapper = getattr(member, '_original_wrapper', None)
|
||||
if not callable(member) or not wrapper:
|
||||
continue
|
||||
|
||||
module.__dict__[name] = wrapper
|
||||
|
||||
|
||||
@pytest.fixture(name='splinter_screenshot_dir', scope='session')
|
||||
def fixture_splinter_screenshot_dir(request):
|
||||
"""Set default screenshot directory to ./screenshots.
|
||||
|
||||
This can be overridden using --splinter-screenshot-dir=foo as the option.
|
||||
"""
|
||||
option = request.config.getoption('--splinter-screenshot-dir')
|
||||
screenshots_dir = option if option != '.' else './screenshots'
|
||||
return os.path.abspath(screenshots_dir)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixture_fix_session_browser_screenshots(request):
|
||||
"""Fix a bug in pytest-splinter for screenshots.
|
||||
|
||||
When using session_browser, pytest-splinter does not take a screenshot when
|
||||
a test has failed. It is uses internal pytest API on the FixtureRequest
|
||||
object. This API was removed in later versions of pytest causing the
|
||||
failure. Re-implement the fixture that has the problem fixing this issue.
|
||||
|
||||
Drop this fixture after a fix is merged and released in pytest-splinter.
|
||||
See: https://github.com/pytest-dev/pytest-splinter/pull/157
|
||||
"""
|
||||
yield
|
||||
|
||||
if not request.config.pluginmanager.has_plugin('pytest-splinter'):
|
||||
return
|
||||
|
||||
session_tmpdir = request.getfixturevalue('session_tmpdir')
|
||||
splinter_session_scoped_browser = request.getfixturevalue(
|
||||
'splinter_session_scoped_browser')
|
||||
splinter_make_screenshot_on_failure = request.getfixturevalue(
|
||||
'splinter_make_screenshot_on_failure')
|
||||
splinter_screenshot_dir = request.getfixturevalue(
|
||||
'splinter_screenshot_dir')
|
||||
splinter_screenshot_getter_html = request.getfixturevalue(
|
||||
'splinter_screenshot_getter_html')
|
||||
splinter_screenshot_getter_png = request.getfixturevalue(
|
||||
'splinter_screenshot_getter_png')
|
||||
splinter_screenshot_encoding = request.getfixturevalue(
|
||||
'splinter_screenshot_encoding')
|
||||
|
||||
# Screenshot for function scoped browsers is handled in
|
||||
# browser_instance_getter
|
||||
if not splinter_session_scoped_browser:
|
||||
return
|
||||
|
||||
for name in request.fixturenames:
|
||||
fixture_def = request._fixture_defs.get(name)
|
||||
if not fixture_def or not fixture_def.cached_result:
|
||||
continue
|
||||
|
||||
value = fixture_def.cached_result[0]
|
||||
should_take_screenshot = (hasattr(value, "__splinter_browser__")
|
||||
and splinter_make_screenshot_on_failure
|
||||
and getattr(request.node, 'splinter_failure',
|
||||
True))
|
||||
|
||||
from pytest_splinter import plugin
|
||||
if should_take_screenshot:
|
||||
kwargs = {
|
||||
'request':
|
||||
request,
|
||||
'fixture_name':
|
||||
name,
|
||||
'session_tmpdir':
|
||||
session_tmpdir,
|
||||
'browser_instance':
|
||||
value,
|
||||
'splinter_screenshot_dir':
|
||||
splinter_screenshot_dir,
|
||||
'splinter_screenshot_getter_html':
|
||||
splinter_screenshot_getter_html,
|
||||
'splinter_screenshot_getter_png':
|
||||
splinter_screenshot_getter_png,
|
||||
'splinter_screenshot_encoding':
|
||||
splinter_screenshot_encoding
|
||||
}
|
||||
|
||||
plugin._take_screenshot(**kwargs)
|
||||
@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Component for managing a background daemon or any systemd unit."""
|
||||
|
||||
import contextlib
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
@ -8,13 +9,17 @@ import psutil
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from plinth import action_utils, app
|
||||
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
DiagnosticCheckParameters, Result)
|
||||
|
||||
|
||||
class Daemon(app.LeaderComponent):
|
||||
"""Component to manage a background daemon or any systemd unit."""
|
||||
|
||||
def __init__(self, component_id, unit, strict_check=False,
|
||||
listen_ports=None, alias=None):
|
||||
def __init__(self, component_id: str, unit: str,
|
||||
strict_check: bool = False,
|
||||
listen_ports: list[tuple[int, str]] | None = None,
|
||||
alias: str | None = None):
|
||||
"""Initialize a new daemon component.
|
||||
|
||||
'component_id' must be a unique string across all apps and components
|
||||
@ -82,7 +87,21 @@ class Daemon(app.LeaderComponent):
|
||||
"""Return whether the daemon/unit is running."""
|
||||
return action_utils.service_is_running(self.unit)
|
||||
|
||||
def diagnose(self):
|
||||
@contextlib.contextmanager
|
||||
def ensure_running(self):
|
||||
"""Ensure a service is running and return to previous state."""
|
||||
from plinth.privileged import service as service_privileged
|
||||
starting_state = self.is_running()
|
||||
if not starting_state:
|
||||
service_privileged.enable(self.unit)
|
||||
|
||||
try:
|
||||
yield starting_state
|
||||
finally:
|
||||
if not starting_state:
|
||||
service_privileged.disable(self.unit)
|
||||
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Check if the daemon is running and listening on expected ports.
|
||||
|
||||
See :py:meth:`plinth.app.Component.diagnose`.
|
||||
@ -95,15 +114,15 @@ class Daemon(app.LeaderComponent):
|
||||
|
||||
return results
|
||||
|
||||
def _diagnose_unit_is_running(self):
|
||||
def _diagnose_unit_is_running(self) -> DiagnosticCheck:
|
||||
"""Check if a daemon is running."""
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
|
||||
check_id = f'daemon-running-{self.unit}'
|
||||
result = Result.PASSED if self.is_running() else Result.FAILED
|
||||
|
||||
description = gettext_noop('Service {service_name} is running')
|
||||
parameters = {'service_name': self.unit}
|
||||
parameters: DiagnosticCheckParameters = {
|
||||
'service_name': str(self.unit)
|
||||
}
|
||||
|
||||
return DiagnosticCheck(check_id, description, result, parameters)
|
||||
|
||||
@ -179,7 +198,9 @@ def app_is_running(app_):
|
||||
return True
|
||||
|
||||
|
||||
def diagnose_port_listening(port, kind='tcp', listen_address=None):
|
||||
def diagnose_port_listening(
|
||||
port: int, kind: str = 'tcp',
|
||||
listen_address: str | None = None) -> DiagnosticCheck:
|
||||
"""Run a diagnostic on whether a port is being listened on.
|
||||
|
||||
Kind must be one of inet, inet4, inet6, tcp, tcp4, tcp6, udp,
|
||||
@ -187,11 +208,9 @@ def diagnose_port_listening(port, kind='tcp', listen_address=None):
|
||||
information.
|
||||
|
||||
"""
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
|
||||
result = _check_port(port, kind, listen_address)
|
||||
|
||||
parameters = {'kind': kind, 'port': port}
|
||||
parameters: DiagnosticCheckParameters = {'kind': kind, 'port': port}
|
||||
if listen_address:
|
||||
parameters['listen_address'] = listen_address
|
||||
check_id = f'daemon-listening-address-{kind}-{port}-{listen_address}'
|
||||
@ -206,7 +225,8 @@ def diagnose_port_listening(port, kind='tcp', listen_address=None):
|
||||
parameters)
|
||||
|
||||
|
||||
def _check_port(port, kind='tcp', listen_address=None):
|
||||
def _check_port(port: int, kind: str = 'tcp',
|
||||
listen_address: str | None = None) -> bool:
|
||||
"""Return whether a port is being listened on."""
|
||||
run_kind = kind
|
||||
|
||||
@ -228,11 +248,12 @@ def _check_port(port, kind='tcp', listen_address=None):
|
||||
continue
|
||||
|
||||
# Port should match
|
||||
if connection.laddr[1] != port:
|
||||
if connection.laddr[1] != port: # type: ignore[misc]
|
||||
continue
|
||||
|
||||
# Listen address if requested should match
|
||||
if listen_address and connection.laddr[0] != listen_address:
|
||||
if listen_address and connection.laddr[
|
||||
0] != listen_address: # type: ignore[misc]
|
||||
continue
|
||||
|
||||
# Special additional checks only for IPv4
|
||||
@ -244,22 +265,21 @@ def _check_port(port, kind='tcp', listen_address=None):
|
||||
return True
|
||||
|
||||
# Full IPv6 address range includes mapped IPv4 address also
|
||||
if connection.laddr[0] == '::':
|
||||
if connection.laddr[0] == '::': # type: ignore[misc]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def diagnose_netcat(host, port, input='', negate=False):
|
||||
def diagnose_netcat(host: str, port: int, remote_input: str = '',
|
||||
negate: bool = False) -> DiagnosticCheck:
|
||||
"""Run a diagnostic using netcat."""
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(['nc', host, str(port)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
process.communicate(input=input.encode())
|
||||
process.communicate(input=remote_input.encode())
|
||||
if process.returncode != 0:
|
||||
result = Result.FAILED if not negate else Result.PASSED
|
||||
else:
|
||||
@ -269,10 +289,13 @@ def diagnose_netcat(host, port, input='', negate=False):
|
||||
|
||||
check_id = f'daemon-netcat-{host}-{port}'
|
||||
description = gettext_noop('Connect to {host}:{port}')
|
||||
parameters = {'host': host, 'port': port, 'negate': negate}
|
||||
parameters: DiagnosticCheckParameters = {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'negate': negate
|
||||
}
|
||||
if negate:
|
||||
check_id = f'daemon-netcat-negate-{host}-{port}'
|
||||
description = gettext_noop('Cannot connect to {host}:{port}')
|
||||
|
||||
return DiagnosticCheck(check_id, description.format(host=host, port=port),
|
||||
result, parameters)
|
||||
return DiagnosticCheck(check_id, description, result, parameters)
|
||||
|
||||
@ -5,11 +5,14 @@ import dataclasses
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import TypeAlias
|
||||
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from plinth.utils import SafeFormatter
|
||||
|
||||
DiagnosticCheckParameters: TypeAlias = dict[str, str | int | bool | None]
|
||||
|
||||
|
||||
class Result(StrEnum):
|
||||
"""The result of a diagnostic check."""
|
||||
@ -26,7 +29,7 @@ class DiagnosticCheck:
|
||||
check_id: str
|
||||
description: str
|
||||
result: Result = Result.NOT_DONE
|
||||
parameters: dict = field(default_factory=dict)
|
||||
parameters: DiagnosticCheckParameters = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def translated_description(self):
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -76,7 +76,9 @@ def action_init():
|
||||
"""Initialize logging for action scripts."""
|
||||
_capture_warnings()
|
||||
|
||||
logging.config.dictConfig(get_configuration())
|
||||
configuration = get_configuration()
|
||||
del configuration['handlers']['console']['formatter']
|
||||
logging.config.dictConfig(configuration)
|
||||
|
||||
|
||||
def init():
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from plinth import app
|
||||
|
||||
@ -101,3 +102,14 @@ def init():
|
||||
parent_url_name='index')
|
||||
Menu('menu-system', icon='fa-cog', url_name='system',
|
||||
parent_url_name='index')
|
||||
|
||||
Menu('menu-system-visibility', name=_('Visibility'), icon='fa-cog',
|
||||
url_name='system:visibility', parent_url_name='system', order=10)
|
||||
Menu('menu-system-data', name=_('Data'), icon='fa-cog',
|
||||
url_name='system:data', parent_url_name='system', order=20)
|
||||
Menu('menu-system-system', name=_('System'), icon='fa-cog',
|
||||
url_name='system:system', parent_url_name='system', order=30)
|
||||
Menu('menu-system-security', name=_('Security'), icon='fa-cog',
|
||||
url_name='system:security', parent_url_name='system', order=40)
|
||||
Menu('menu-system-administration', name=_('Administration'), icon='fa-cog',
|
||||
url_name='system:administration', parent_url_name='system', order=50)
|
||||
|
||||
@ -32,7 +32,8 @@ def _collect_operations_results(request, app):
|
||||
operations = operation_module.manager.collect_results(app.app_id)
|
||||
for operation in operations:
|
||||
if operation.exception:
|
||||
messages.error(request, operation.message)
|
||||
views.messages_error(request, operation.message,
|
||||
operation.exception)
|
||||
else:
|
||||
messages.success(request, operation.message)
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ class ApacheApp(app_module.App):
|
||||
|
||||
_version = 12
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -61,8 +61,8 @@ class ApacheApp(app_module.App):
|
||||
daemon = Daemon('daemon-apache', 'apache2')
|
||||
self.add(daemon)
|
||||
|
||||
daemon = RelatedDaemon('related-daemon-apache', 'uwsgi')
|
||||
self.add(daemon)
|
||||
related_daemon = RelatedDaemon('related-daemon-apache', 'uwsgi')
|
||||
self.add(related_daemon)
|
||||
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
|
||||
@ -7,7 +7,8 @@ import subprocess
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from plinth import action_utils, app
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
DiagnosticCheckParameters, Result)
|
||||
from plinth.privileged import service as service_privileged
|
||||
|
||||
from . import privileged
|
||||
@ -58,7 +59,7 @@ class Webserver(app.LeaderComponent):
|
||||
"""Disable the Apache configuration."""
|
||||
privileged.disable(self.web_name, self.kind)
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Check if the web path is accessible by clients.
|
||||
|
||||
See :py:meth:`plinth.app.Component.diagnose`.
|
||||
@ -135,8 +136,12 @@ class Uwsgi(app.LeaderComponent):
|
||||
and action_utils.service_is_running('uwsgi')
|
||||
|
||||
|
||||
def diagnose_url(url, kind=None, env=None, check_certificate=True,
|
||||
extra_options=None, wrapper=None, expected_output=None):
|
||||
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) -> DiagnosticCheck:
|
||||
"""Run a diagnostic on whether a URL is accessible.
|
||||
|
||||
Kind can be '4' for IPv4 or '6' for IPv6.
|
||||
@ -148,7 +153,7 @@ def diagnose_url(url, kind=None, env=None, check_certificate=True,
|
||||
except FileNotFoundError:
|
||||
result = Result.ERROR
|
||||
|
||||
parameters = {'url': url, 'kind': kind}
|
||||
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}')
|
||||
@ -159,7 +164,8 @@ def diagnose_url(url, kind=None, env=None, check_certificate=True,
|
||||
return DiagnosticCheck(check_id, description, result, parameters)
|
||||
|
||||
|
||||
def diagnose_url_on_all(url, expect_redirects=False, **kwargs):
|
||||
def diagnose_url_on_all(url: str, expect_redirects: bool = False,
|
||||
**kwargs) -> list[DiagnosticCheck]:
|
||||
"""Run a diagnostic on whether a URL is accessible."""
|
||||
results = []
|
||||
for address in action_utils.get_addresses():
|
||||
@ -173,8 +179,12 @@ def diagnose_url_on_all(url, expect_redirects=False, **kwargs):
|
||||
return results
|
||||
|
||||
|
||||
def check_url(url, kind=None, env=None, check_certificate=True,
|
||||
extra_options=None, wrapper=None, expected_output=None):
|
||||
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}']
|
||||
|
||||
|
||||
@ -9,10 +9,10 @@ from unittest.mock import call, patch
|
||||
import pytest
|
||||
|
||||
from plinth import app
|
||||
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||
from plinth.modules.apache.components import (Uwsgi, Webserver, check_url,
|
||||
diagnose_url,
|
||||
diagnose_url_on_all)
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
|
||||
|
||||
def test_webserver_init():
|
||||
|
||||
@ -13,7 +13,7 @@ class ApiApp(app_module.App):
|
||||
|
||||
_version = 1
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ class AvahiApp(app_module.App):
|
||||
|
||||
_version = 1
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -50,7 +50,8 @@ class AvahiApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-avahi', info.name, None, info.icon,
|
||||
'avahi:index', parent_url_name='system')
|
||||
'avahi:index',
|
||||
parent_url_name='system:visibility', order=50)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-avahi', ['avahi-daemon', 'avahi-utils'])
|
||||
|
||||
@ -35,7 +35,7 @@ class BackupsApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -47,7 +47,8 @@ class BackupsApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-backups', info.name, None, info.icon,
|
||||
'backups:index', parent_url_name='system')
|
||||
'backups:index', parent_url_name='system:data',
|
||||
order=20)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-backups', ['borgbackup', 'sshfs'])
|
||||
|
||||
@ -275,7 +275,9 @@ class BaseBorgRepository(abc.ABC):
|
||||
@staticmethod
|
||||
def reraise_known_error(err):
|
||||
"""Look whether the caught error is known and reraise it accordingly"""
|
||||
caught_error = str((err, err.args))
|
||||
stdout = getattr(err, 'stdout', b'').decode()
|
||||
stderr = getattr(err, 'stderr', b'').decode()
|
||||
caught_error = str((err, err.args, stdout, stderr))
|
||||
for known_error in KNOWN_ERRORS:
|
||||
for error in known_error['errors']:
|
||||
if re.search(error, caught_error):
|
||||
|
||||
@ -50,7 +50,7 @@ class BepastyApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ class BindApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -44,7 +44,7 @@ class BindApp(app_module.App):
|
||||
|
||||
menu_item = menu.Menu('menu-bind', info.name, info.short_description,
|
||||
info.icon, 'bind:index',
|
||||
parent_url_name='system')
|
||||
parent_url_name='system:visibility', order=30)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-bind', ['bind9'])
|
||||
|
||||
@ -46,7 +46,7 @@ class CalibreApp(app_module.App):
|
||||
|
||||
DAEMON = 'calibre-server-freedombox'
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ class CockpitApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -59,7 +59,9 @@ class CockpitApp(app_module.App):
|
||||
|
||||
menu_item = menu.Menu('menu-cockpit', info.name,
|
||||
info.short_description, info.icon,
|
||||
'cockpit:index', parent_url_name='system')
|
||||
'cockpit:index',
|
||||
parent_url_name='system:administration',
|
||||
order=20)
|
||||
self.add(menu_item)
|
||||
|
||||
shortcut = frontpage.Shortcut('shortcut-cockpit', info.name,
|
||||
|
||||
@ -35,7 +35,7 @@ class ConfigApp(app_module.App):
|
||||
|
||||
can_be_disabled = False
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
info = app_module.Info(app_id=self.app_id, version=self._version,
|
||||
@ -47,7 +47,8 @@ class ConfigApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-config', _('Configure'), None, info.icon,
|
||||
'config:index', parent_url_name='system')
|
||||
'config:index', parent_url_name='system:system',
|
||||
order=30)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-config', ['zram-tools'])
|
||||
|
||||
@ -44,7 +44,7 @@ class CoturnApp(app_module.App):
|
||||
|
||||
_version = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ from django.utils.translation import gettext_noop
|
||||
from plinth import app as app_module
|
||||
from plinth import menu
|
||||
from plinth.daemon import Daemon, RelatedDaemon
|
||||
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
from plinth.package import Packages
|
||||
|
||||
from . import manifest
|
||||
@ -60,7 +60,7 @@ class DateTimeApp(app_module.App):
|
||||
|
||||
return self._time_managed
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -71,15 +71,16 @@ class DateTimeApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-datetime', info.name, None, info.icon,
|
||||
'datetime:index', parent_url_name='system')
|
||||
'datetime:index',
|
||||
parent_url_name='system:system', order=40)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-datetime', ['systemd-timesyncd'])
|
||||
self.add(packages)
|
||||
|
||||
daemon = RelatedDaemon('daemon-datetime-timedated',
|
||||
'systemd-timedated')
|
||||
self.add(daemon)
|
||||
related_daemon = RelatedDaemon('daemon-datetime-timedated',
|
||||
'systemd-timedated')
|
||||
self.add(related_daemon)
|
||||
|
||||
if self._is_time_managed():
|
||||
daemon = Daemon('daemon-datetime', 'systemd-timesyncd')
|
||||
@ -89,7 +90,7 @@ class DateTimeApp(app_module.App):
|
||||
**manifest.backup)
|
||||
self.add(backup_restore)
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return the results."""
|
||||
results = super().diagnose()
|
||||
if self._is_time_managed():
|
||||
@ -107,7 +108,7 @@ class DateTimeApp(app_module.App):
|
||||
self.enable()
|
||||
|
||||
|
||||
def _diagnose_time_synchronized():
|
||||
def _diagnose_time_synchronized() -> DiagnosticCheck:
|
||||
"""Check whether time is synchronized to NTP server."""
|
||||
result = Result.FAILED
|
||||
try:
|
||||
|
||||
@ -33,7 +33,7 @@ class DelugeApp(app_module.App):
|
||||
|
||||
_version = 8
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -17,11 +17,12 @@ from django.utils.translation import gettext_noop
|
||||
from plinth import app as app_module
|
||||
from plinth import daemon, glib, kvstore, menu
|
||||
from plinth import operation as operation_module
|
||||
from plinth.diagnostic_check import (CheckJSONDecoder, CheckJSONEncoder,
|
||||
DiagnosticCheck, Result)
|
||||
from plinth.modules.apache.components import diagnose_url_on_all
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
|
||||
from . import manifest
|
||||
from .check import CheckJSONDecoder, CheckJSONEncoder, Result
|
||||
|
||||
_description = [
|
||||
_('The system diagnostic test will run a number of checks on your '
|
||||
@ -44,7 +45,7 @@ class DiagnosticsApp(app_module.App):
|
||||
|
||||
can_be_disabled = False
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
info = app_module.Info(app_id=self.app_id, version=self._version,
|
||||
@ -54,7 +55,9 @@ class DiagnosticsApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-diagnostics', info.name, None, info.icon,
|
||||
'diagnostics:index', parent_url_name='system')
|
||||
'diagnostics:index',
|
||||
parent_url_name='system:administration',
|
||||
order=30)
|
||||
self.add(menu_item)
|
||||
|
||||
backup_restore = BackupRestore('backup-restore-diagnostics',
|
||||
@ -75,7 +78,7 @@ class DiagnosticsApp(app_module.App):
|
||||
super().setup(old_version)
|
||||
self.enable()
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return the results."""
|
||||
results = super().diagnose()
|
||||
results.append(daemon.diagnose_port_listening(8000, 'tcp4'))
|
||||
@ -103,7 +106,8 @@ def _run_on_all_enabled_modules():
|
||||
current_results = {
|
||||
'apps': [],
|
||||
'results': collections.OrderedDict(),
|
||||
'progress_percentage': 0
|
||||
'progress_percentage': 0,
|
||||
'exception': None,
|
||||
}
|
||||
|
||||
for app in app_module.App.list():
|
||||
@ -335,15 +339,26 @@ def are_results_available():
|
||||
return bool(results)
|
||||
|
||||
|
||||
def get_results():
|
||||
def get_results() -> dict:
|
||||
"""Return the latest results of full diagnostics."""
|
||||
global current_results
|
||||
|
||||
with results_lock:
|
||||
results = deepcopy(current_results)
|
||||
try:
|
||||
results = deepcopy(current_results)
|
||||
except TypeError as error:
|
||||
# See #2410: cannot pickle 'dict_values' object
|
||||
logger.error('Cannot get diagnostic results: %s - %s', error,
|
||||
current_results)
|
||||
exception = str(error) + ' - ' + str(current_results)
|
||||
# Clear the results that can't be used.
|
||||
current_results = {}
|
||||
return {'exception': exception}
|
||||
|
||||
# If no results are available in memory, then load from database.
|
||||
if not results:
|
||||
results = kvstore.get_default('diagnostics_results', '{}')
|
||||
results = json.loads(results, cls=CheckJSONDecoder)
|
||||
results = json.loads(str(results), cls=CheckJSONDecoder)
|
||||
results = {'results': results, 'progress_percentage': 100}
|
||||
|
||||
# Add a translated name for each app
|
||||
|
||||
58
plinth/modules/diagnostics/tests/test_diagnostics.py
Normal file
58
plinth/modules/diagnostics/tests/test_diagnostics.py
Normal file
@ -0,0 +1,58 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Tests for Diagnostics app functions."""
|
||||
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import patch
|
||||
|
||||
from plinth.app import App, Info
|
||||
from plinth.modules.diagnostics import get_results
|
||||
|
||||
|
||||
class AppTest(App):
|
||||
"""Sample App for testing."""
|
||||
app_id = 'test-app'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
info = Info('test-app', 1)
|
||||
self.add(info)
|
||||
|
||||
|
||||
def test_get_results():
|
||||
"""Test getting the diagnostics results."""
|
||||
var = 'plinth.modules.diagnostics.current_results'
|
||||
with patch(var, {}):
|
||||
assert get_results() == {'progress_percentage': 100, 'results': {}}
|
||||
|
||||
with patch(var, {
|
||||
'apps': [],
|
||||
'results': OrderedDict(),
|
||||
'progress_percentage': 0
|
||||
}):
|
||||
assert get_results() == {
|
||||
'apps': [],
|
||||
'results': {},
|
||||
'progress_percentage': 0
|
||||
}
|
||||
|
||||
_ = AppTest()
|
||||
results = OrderedDict({
|
||||
'test-app': {
|
||||
'id': 'test-app',
|
||||
'diagnosis': [],
|
||||
'exception': None,
|
||||
'show_rerun_setup': False
|
||||
}
|
||||
})
|
||||
with patch(
|
||||
var, {
|
||||
'apps': [('test-app', AppTest)],
|
||||
'results': results,
|
||||
'progress_percentage': 0
|
||||
}):
|
||||
results['test-app'].update({'name': 'test-app'})
|
||||
assert get_results() == {
|
||||
'apps': [('test-app', AppTest)],
|
||||
'results': results,
|
||||
'progress_percentage': 0
|
||||
}
|
||||
@ -14,10 +14,10 @@ from django.views.generic import TemplateView
|
||||
|
||||
from plinth import operation
|
||||
from plinth.app import App
|
||||
from plinth.diagnostic_check import Result
|
||||
from plinth.modules import diagnostics
|
||||
from plinth.views import AppView
|
||||
|
||||
from .check import Result
|
||||
from .forms import ConfigureForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -75,6 +75,10 @@ class DiagnosticsFullView(TemplateView):
|
||||
context['is_task_running'] = is_task_running
|
||||
context['results'] = diagnostics.get_results()
|
||||
context['refresh_page_sec'] = 3 if is_task_running else None
|
||||
exception = context['results'].pop('exception', None)
|
||||
if exception:
|
||||
messages.error(self.request, exception)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ class DynamicDNSApp(app_module.App):
|
||||
|
||||
_version = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -64,7 +64,8 @@ class DynamicDNSApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-dynamicdns', info.name, None, info.icon,
|
||||
'dynamicdns:index', parent_url_name='system')
|
||||
'dynamicdns:index',
|
||||
parent_url_name='system:visibility', order=20)
|
||||
self.add(menu_item)
|
||||
|
||||
enable_state = app_module.EnableState('enable-state-dynamicdns')
|
||||
|
||||
@ -52,7 +52,7 @@ class EjabberdApp(app_module.App):
|
||||
|
||||
_version = 8
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ class EmailApp(plinth.app.App):
|
||||
|
||||
_version = 4
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the email app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@ from django.utils.translation import gettext_noop
|
||||
from plinth import app as app_module
|
||||
from plinth import cfg, menu
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
from plinth.package import Packages, install
|
||||
from plinth.utils import Version, format_lazy, import_from_gi
|
||||
|
||||
@ -53,7 +53,7 @@ class FirewallApp(app_module.App):
|
||||
|
||||
can_be_disabled = False
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -64,7 +64,8 @@ class FirewallApp(app_module.App):
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-firewall', info.name, None, info.icon,
|
||||
'firewall:index', parent_url_name='system')
|
||||
'firewall:index',
|
||||
parent_url_name='system:security', order=30)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-firewall', ['firewalld', 'nftables'])
|
||||
@ -96,7 +97,7 @@ class FirewallApp(app_module.App):
|
||||
_run_setup()
|
||||
return True
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return the results."""
|
||||
results = super().diagnose()
|
||||
config = privileged.get_config()
|
||||
@ -265,7 +266,8 @@ def remove_passthrough(ipv, *args):
|
||||
config_direct.removePassthrough('(sas)', ipv, args)
|
||||
|
||||
|
||||
def _diagnose_default_zone(config):
|
||||
def _diagnose_default_zone(
|
||||
config: privileged.FirewallConfig) -> DiagnosticCheck:
|
||||
"""Diagnose whether the default zone is external."""
|
||||
check_id = 'firewall-default-zone'
|
||||
description = gettext_noop('Default zone is external')
|
||||
@ -274,7 +276,8 @@ def _diagnose_default_zone(config):
|
||||
return DiagnosticCheck(check_id, description, result)
|
||||
|
||||
|
||||
def _diagnose_firewall_backend(config):
|
||||
def _diagnose_firewall_backend(
|
||||
config: privileged.FirewallConfig) -> DiagnosticCheck:
|
||||
"""Diagnose whether the firewall backend is nftables."""
|
||||
check_id = 'firewall-backend'
|
||||
description = gettext_noop('Firewall backend is nftables')
|
||||
@ -283,7 +286,8 @@ def _diagnose_firewall_backend(config):
|
||||
return DiagnosticCheck(check_id, description, result)
|
||||
|
||||
|
||||
def _diagnose_direct_passthroughs(config):
|
||||
def _diagnose_direct_passthroughs(
|
||||
config: privileged.FirewallConfig) -> DiagnosticCheck:
|
||||
"""Diagnose direct passthroughs for local service protection.
|
||||
|
||||
Currently, we just check that the number of passthroughs is at least 12,
|
||||
|
||||
@ -5,16 +5,19 @@ App component for other apps to use firewall functionality.
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, TypeAlias
|
||||
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from plinth import app
|
||||
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
DiagnosticCheckParameters, Result)
|
||||
from plinth.modules import firewall
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_list_type: TypeAlias = list
|
||||
|
||||
|
||||
class Firewall(app.FollowerComponent):
|
||||
"""Component to open/close firewall ports for an app."""
|
||||
@ -114,7 +117,7 @@ class Firewall(app.FollowerComponent):
|
||||
if not re.fullmatch(r'tun\d+', interface)
|
||||
]
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> _list_type[DiagnosticCheck]:
|
||||
"""Check if the firewall ports are open and only as expected.
|
||||
|
||||
See :py:meth:`plinth.app.Component.diagnose`.
|
||||
@ -124,7 +127,7 @@ class Firewall(app.FollowerComponent):
|
||||
internal_ports = firewall.get_enabled_services(zone='internal')
|
||||
external_ports = firewall.get_enabled_services(zone='external')
|
||||
for port_detail in self.ports_details:
|
||||
port = port_detail['name']
|
||||
port = str(port_detail['name'])
|
||||
details = ', '.join(
|
||||
(f'{port_number}/{protocol}'
|
||||
for port_number, protocol in port_detail['details']))
|
||||
@ -134,7 +137,10 @@ class Firewall(app.FollowerComponent):
|
||||
result = Result.PASSED if port in internal_ports else Result.FAILED
|
||||
description = gettext_noop(
|
||||
'Port {name} ({details}) available for internal networks')
|
||||
parameters = {'name': port, 'details': details}
|
||||
parameters: DiagnosticCheckParameters = {
|
||||
'name': port,
|
||||
'details': details
|
||||
}
|
||||
results.append(
|
||||
DiagnosticCheck(check_id, description, result, parameters))
|
||||
|
||||
|
||||
@ -2,12 +2,15 @@
|
||||
"""Configuration helper for FreedomBox firewall interface."""
|
||||
|
||||
import subprocess
|
||||
from typing import TypeAlias
|
||||
|
||||
import augeas
|
||||
|
||||
from plinth import action_utils
|
||||
from plinth.actions import privileged
|
||||
|
||||
FirewallConfig: TypeAlias = dict[str, str | list[str]]
|
||||
|
||||
|
||||
def _flush_iptables_rules():
|
||||
"""Flush firewalld iptables rules before restarting it.
|
||||
@ -132,9 +135,9 @@ def setup():
|
||||
|
||||
|
||||
@privileged
|
||||
def get_config():
|
||||
def get_config() -> FirewallConfig:
|
||||
"""Return firewalld configuration for diagnostics."""
|
||||
config = {}
|
||||
config: FirewallConfig = {}
|
||||
|
||||
# Get the default zone.
|
||||
output = subprocess.check_output(['firewall-cmd', '--get-default-zone'])
|
||||
|
||||
@ -8,7 +8,7 @@ from unittest.mock import call, patch
|
||||
import pytest
|
||||
|
||||
from plinth.app import App
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||
from plinth.modules.firewall.components import (Firewall,
|
||||
FirewallLocalProtection)
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ class FirstBootApp(app_module.App):
|
||||
|
||||
_version = 1
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -38,14 +38,12 @@ class GitwebApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
groups = {'git-access': _('Read-write access to Git repositories')}
|
||||
|
||||
self.repos = []
|
||||
|
||||
info = app_module.Info(app_id=self.app_id, version=self._version,
|
||||
name=_('Gitweb'), icon_filename='gitweb',
|
||||
short_description=_('Simple Git Hosting'),
|
||||
@ -98,7 +96,7 @@ class GitwebApp(app_module.App):
|
||||
|
||||
def post_init(self):
|
||||
"""Perform post initialization operations."""
|
||||
if not self.needs_setup() and self.is_enabled():
|
||||
if not self.needs_setup():
|
||||
self.update_service_access()
|
||||
|
||||
def set_shortcut_login_required(self, login_required):
|
||||
@ -122,7 +120,7 @@ class GitwebApp(app_module.App):
|
||||
|
||||
def _disable_public_access(self):
|
||||
"""Allow Gitweb app to be accessed by logged-in users only."""
|
||||
if not self.auth_webserver.is_conf_enabled():
|
||||
if self.is_enabled() and not self.auth_webserver.is_conf_enabled():
|
||||
self.auth_webserver.enable()
|
||||
|
||||
self.set_shortcut_login_required(True)
|
||||
|
||||
@ -63,23 +63,30 @@ class TestGitwebApp(functional.BaseAppTests):
|
||||
|
||||
@pytest.mark.parametrize('access', ['public', 'private'])
|
||||
@pytest.mark.parametrize('repo_name', ['Test-repo', 'Test-repo.git'])
|
||||
def test_create_delete_repo(self, session_browser, access, repo_name):
|
||||
@pytest.mark.parametrize('app_status', ['enabled', 'disabled'])
|
||||
def test_create_delete_repo(self, session_browser, access, repo_name,
|
||||
app_status):
|
||||
"""Test creating and deleting a repo and accessing with a git
|
||||
client."""
|
||||
if app_status == "disabled":
|
||||
functional.app_disable(session_browser, 'gitweb')
|
||||
|
||||
_delete_repo(session_browser, repo_name, ignore_missing=True)
|
||||
_create_repo(session_browser, repo_name, access)
|
||||
|
||||
assert _repo_exists(session_browser, repo_name, access)
|
||||
assert _site_repo_exists(session_browser, repo_name)
|
||||
|
||||
if access == "public":
|
||||
assert _repo_is_readable(repo_name)
|
||||
else:
|
||||
assert not _repo_is_readable(repo_name)
|
||||
if app_status == "enabled":
|
||||
assert _site_repo_exists(session_browser, repo_name)
|
||||
|
||||
assert not _repo_is_writable(repo_name)
|
||||
assert _repo_is_readable(repo_name, with_auth=True)
|
||||
assert _repo_is_writable(repo_name, with_auth=True)
|
||||
if access == "public":
|
||||
assert _repo_is_readable(repo_name)
|
||||
else:
|
||||
assert not _repo_is_readable(repo_name)
|
||||
|
||||
assert not _repo_is_writable(repo_name)
|
||||
assert _repo_is_readable(repo_name, with_auth=True)
|
||||
assert _repo_is_writable(repo_name, with_auth=True)
|
||||
|
||||
_delete_repo(session_browser, repo_name)
|
||||
assert not _repo_exists(session_browser, repo_name)
|
||||
@ -93,8 +100,12 @@ class TestGitwebApp(functional.BaseAppTests):
|
||||
assert _site_repo_exists(session_browser, 'Test-repo')
|
||||
assert not _site_repo_exists(session_browser, 'Test-repo-private')
|
||||
|
||||
def test_edit_repo_metadata(self, session_browser):
|
||||
@pytest.mark.parametrize('app_status', ['enabled', 'disabled'])
|
||||
def test_edit_repo_metadata(self, session_browser, app_status):
|
||||
"""Test edit repo metadata."""
|
||||
if app_status == "disabled":
|
||||
functional.app_disable(session_browser, 'gitweb')
|
||||
|
||||
_create_repo(session_browser, 'Test-repo2', 'public',
|
||||
ok_if_exists=True)
|
||||
_delete_repo(session_browser, 'Test-repo', ignore_missing=True)
|
||||
@ -108,10 +119,12 @@ class TestGitwebApp(functional.BaseAppTests):
|
||||
assert _get_repo_metadata(session_browser,
|
||||
"Test-repo") == repo_metadata
|
||||
|
||||
_create_branch('Test-repo', 'branch1')
|
||||
_set_default_branch(session_browser, 'Test-repo', 'branch1')
|
||||
assert _get_gitweb_site_default_repo_branch(session_browser,
|
||||
'Test-repo') == 'branch1'
|
||||
if app_status == "enabled":
|
||||
_create_branch('Test-repo', 'branch1')
|
||||
_set_default_branch(session_browser, 'Test-repo', 'branch1')
|
||||
|
||||
assert _get_gitweb_site_default_repo_branch(
|
||||
session_browser, 'Test-repo') == 'branch1'
|
||||
|
||||
|
||||
def _create_local_repo(path):
|
||||
|
||||
@ -22,7 +22,7 @@ class HelpApp(app_module.App):
|
||||
|
||||
_version = 1
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ class I2PApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ class IkiwikiApp(app_module.App):
|
||||
|
||||
_version = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ class InfinotedApp(app_module.App):
|
||||
|
||||
_version = 3
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ class JanusApp(app_module.App):
|
||||
|
||||
_version = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ class JSXCApp(app_module.App):
|
||||
|
||||
_version = 1
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ class KiwixApp(app_module.App):
|
||||
|
||||
DAEMON = 'kiwix-server-freedombox'
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ from django.utils.translation import gettext_noop
|
||||
from plinth import app as app_module
|
||||
from plinth import cfg, menu
|
||||
from plinth.config import DropinConfigs
|
||||
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||
from plinth.modules import names
|
||||
from plinth.modules.apache.components import diagnose_url
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.diagnostics.check import DiagnosticCheck, Result
|
||||
from plinth.modules.names.components import DomainType
|
||||
from plinth.package import Packages
|
||||
from plinth.signals import domain_added, domain_removed, post_app_loading
|
||||
@ -51,7 +51,7 @@ class LetsEncryptApp(app_module.App):
|
||||
|
||||
can_be_disabled = False
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
@ -66,7 +66,8 @@ class LetsEncryptApp(app_module.App):
|
||||
|
||||
menu_item = menu.Menu('menu-letsencrypt', info.name,
|
||||
info.short_description, info.icon,
|
||||
'letsencrypt:index', parent_url_name='system')
|
||||
'letsencrypt:index',
|
||||
parent_url_name='system:security', order=20)
|
||||
self.add(menu_item)
|
||||
|
||||
packages = Packages('packages-letsencrypt', ['certbot'])
|
||||
@ -89,7 +90,7 @@ class LetsEncryptApp(app_module.App):
|
||||
|
||||
post_app_loading.connect(_certificate_handle_modified)
|
||||
|
||||
def diagnose(self):
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return the results."""
|
||||
results = super().diagnose()
|
||||
|
||||
@ -186,9 +187,8 @@ def on_domain_removed(sender, domain_type, name='', **kwargs):
|
||||
logger.info('Revoking certificate for %s', name)
|
||||
certificate_revoke(name, really_revoke=False)
|
||||
return True
|
||||
except Exception as exception:
|
||||
logger.warning('Failed to revoke certificate for %s: %s', name,
|
||||
exception.args[2])
|
||||
except Exception:
|
||||
logger.warning('Failed to revoke certificate for %s', name)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@ -142,12 +142,7 @@ def revoke(domain: str):
|
||||
if TEST_MODE:
|
||||
command.append('--staging')
|
||||
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
_, stderr = process.communicate()
|
||||
if process.returncode:
|
||||
raise RuntimeError('Error revoking certificate: {error}'.format(
|
||||
error=stderr.decode()))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
action_utils.webserver_disable(domain, kind='site')
|
||||
|
||||
@ -164,12 +159,7 @@ def obtain(domain: str):
|
||||
if TEST_MODE:
|
||||
command.append('--staging')
|
||||
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
_, stderr = process.communicate()
|
||||
if process.returncode:
|
||||
raise RuntimeError('Error obtaining certificate: {error}'.format(
|
||||
error=stderr.decode()))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
with action_utils.WebserverChange() as webserver_change:
|
||||
_setup_webserver_config(domain, webserver_change)
|
||||
@ -330,13 +320,7 @@ def _assert_managed_path(module, path):
|
||||
def delete(domain: str):
|
||||
"""Disable a domain and delete the certificate."""
|
||||
command = ['certbot', 'delete', '--non-interactive', '--cert-name', domain]
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
_, stderr = process.communicate()
|
||||
if process.returncode:
|
||||
raise RuntimeError('Error deleting certificate: {error}'.format(
|
||||
error=stderr.decode()))
|
||||
|
||||
subprocess.run(command, check=True)
|
||||
action_utils.webserver_disable(domain, kind='site')
|
||||
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from plinth.modules import letsencrypt
|
||||
from plinth.views import AppView
|
||||
from plinth.views import AppView, messages_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -41,10 +41,10 @@ def revoke(request, domain):
|
||||
'This may take a few moments to take effect.').format(
|
||||
domain=domain))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
messages_error(
|
||||
request,
|
||||
_('Failed to revoke certificate for domain {domain}: {error}').
|
||||
format(domain=domain, error=exception.args))
|
||||
_('Failed to revoke certificate for domain {domain}').format(
|
||||
domain=domain), exception)
|
||||
|
||||
return redirect(reverse_lazy('letsencrypt:index'))
|
||||
|
||||
@ -59,10 +59,11 @@ def obtain(request, domain):
|
||||
_('Certificate successfully obtained for domain {domain}').format(
|
||||
domain=domain))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
messages_error(
|
||||
request,
|
||||
_('Failed to obtain certificate for domain {domain}: {error}').
|
||||
format(domain=domain, error=exception.args))
|
||||
_('Failed to obtain certificate for domain {domain}').format(
|
||||
domain=domain), exception)
|
||||
|
||||
return redirect(reverse_lazy('letsencrypt:index'))
|
||||
|
||||
|
||||
@ -76,10 +77,11 @@ def reobtain(request, domain):
|
||||
_('Certificate successfully obtained for domain {domain}').format(
|
||||
domain=domain))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
messages_error(
|
||||
request,
|
||||
_('Failed to obtain certificate for domain {domain}: {error}').
|
||||
format(domain=domain, error=exception.args))
|
||||
_('Failed to obtain certificate for domain {domain}').format(
|
||||
domain=domain), exception)
|
||||
|
||||
return redirect(reverse_lazy('letsencrypt:index'))
|
||||
|
||||
|
||||
@ -93,9 +95,9 @@ def delete(request, domain):
|
||||
_('Certificate successfully deleted for domain {domain}').format(
|
||||
domain=domain))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
messages_error(
|
||||
request,
|
||||
_('Failed to delete certificate for domain {domain}: {error}').
|
||||
format(domain=domain, error=exception.args))
|
||||
_('Failed to delete certificate for domain {domain}').format(
|
||||
domain=domain), exception)
|
||||
|
||||
return redirect(reverse_lazy('letsencrypt:index'))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user