mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-20 10:34:30 +00:00
Compare commits
15 Commits
4220511eb7
...
cf3bc4aae1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3bc4aae1 | ||
|
|
2f53c4dd39 | ||
|
|
e26a4b71eb | ||
|
|
3f19c91007 | ||
|
|
80705b85af | ||
|
|
a70611a2e9 | ||
|
|
213d0330fd | ||
|
|
99c28b583f | ||
|
|
847de4d570 | ||
|
|
71a50e6d19 | ||
|
|
a6089664eb | ||
|
|
c47a856e3e | ||
|
|
72bcb93f56 | ||
|
|
f2edc6ab2b | ||
|
|
e100c89ecc |
18
Makefile
18
Makefile
@ -98,6 +98,7 @@ install:
|
|||||||
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/COPYING.md && \
|
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/COPYING.md && \
|
||||||
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/direct_url.json && \
|
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/direct_url.json && \
|
||||||
$(INSTALL) -D -t $(BIN_DIR) bin/plinth
|
$(INSTALL) -D -t $(BIN_DIR) bin/plinth
|
||||||
|
$(INSTALL) -D -t $(BIN_DIR) bin/freedombox-privileged
|
||||||
|
|
||||||
# Actions
|
# Actions
|
||||||
$(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions
|
$(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions
|
||||||
@ -150,6 +151,13 @@ clean:
|
|||||||
rm -rf Plinth.egg-info
|
rm -rf Plinth.egg-info
|
||||||
find plinth/locale -name *.mo -delete
|
find plinth/locale -name *.mo -delete
|
||||||
|
|
||||||
|
define DEVELOP_SERVICE_CONF
|
||||||
|
[Service]
|
||||||
|
Environment=FREEDOMBOX_DEVELOP=1
|
||||||
|
Environment=PYTHONPATH=/freedombox/
|
||||||
|
endef
|
||||||
|
export DEVELOP_SERVICE_CONF
|
||||||
|
|
||||||
# Run basic setup for a developer environment (VM or container)
|
# Run basic setup for a developer environment (VM or container)
|
||||||
provision-dev:
|
provision-dev:
|
||||||
# Install newer build dependencies if any
|
# Install newer build dependencies if any
|
||||||
@ -159,9 +167,19 @@ provision-dev:
|
|||||||
# Install latest code over .deb
|
# Install latest code over .deb
|
||||||
$(MAKE) build install
|
$(MAKE) build install
|
||||||
|
|
||||||
|
# Configure privileged daemon for development setup
|
||||||
|
mkdir -p /etc/systemd/system/freedombox-privileged.service.d/
|
||||||
|
echo "$$DEVELOP_SERVICE_CONF" > /etc/systemd/system/freedombox-privileged.service.d/develop.conf
|
||||||
|
|
||||||
# Reload newer systemd units, ignore failure
|
# Reload newer systemd units, ignore failure
|
||||||
-systemctl daemon-reload
|
-systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable privileged daemon
|
||||||
|
-systemctl stop freedombox-privileged.service
|
||||||
|
|
||||||
|
-test -d /run/systemd/system && \
|
||||||
|
systemctl enable --now freedombox-privileged.socket
|
||||||
|
|
||||||
# Stop any ongoing upgrade, ignore failure
|
# Stop any ongoing upgrade, ignore failure
|
||||||
-killall -9 unattended-upgr
|
-killall -9 unattended-upgr
|
||||||
|
|
||||||
|
|||||||
6
bin/freedombox-privileged
Executable file
6
bin/freedombox-privileged
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import plinth.privileged_daemon
|
||||||
|
|
||||||
|
plinth.privileged_daemon.main()
|
||||||
21
data/usr/lib/systemd/system/freedombox-privileged.service
Normal file
21
data/usr/lib/systemd/system/freedombox-privileged.service
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=FreedomBox Privileged Service
|
||||||
|
Documentation=https://wiki.debian.org/FreedomBox/
|
||||||
|
# Don't hit the start rate limiting.
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
ExecStart=/usr/bin/freedombox-privileged
|
||||||
|
TimeoutSec=300s
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
NotifyAccess=main
|
||||||
|
PrivateTmp=yes
|
||||||
|
Restart=on-failure
|
||||||
|
# Don't restart too fast
|
||||||
|
RestartSec=1
|
||||||
|
RestartSteps=3
|
||||||
|
RestartMaxDelaySec=5
|
||||||
16
data/usr/lib/systemd/system/freedombox-privileged.socket
Normal file
16
data/usr/lib/systemd/system/freedombox-privileged.socket
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=FreedomBox Privileged Service Socket
|
||||||
|
Documentation=https://wiki.debian.org/FreedomBox/
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
Accept=no
|
||||||
|
ListenStream=/run/freedombox/privileged.socket
|
||||||
|
SocketUser=root
|
||||||
|
SocketGroup=root
|
||||||
|
SocketMode=0666
|
||||||
|
DirectoryMode=755
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
1
debian/control
vendored
1
debian/control
vendored
@ -50,7 +50,6 @@ Build-Depends:
|
|||||||
python3-requests,
|
python3-requests,
|
||||||
python3-ruamel.yaml,
|
python3-ruamel.yaml,
|
||||||
python3-setuptools,
|
python3-setuptools,
|
||||||
python3-setuptools-git,
|
|
||||||
python3-systemd,
|
python3-systemd,
|
||||||
python3-typeshed,
|
python3-typeshed,
|
||||||
python3-yaml,
|
python3-yaml,
|
||||||
|
|||||||
3
debian/rules
vendored
3
debian/rules
vendored
@ -34,4 +34,5 @@ override_dh_installsystemd:
|
|||||||
# (as of debhelper 13.5.2) that still has hardcoded search path of
|
# (as of debhelper 13.5.2) that still has hardcoded search path of
|
||||||
# /lib/systemd/system for searching systemd services. See #987989 and
|
# /lib/systemd/system for searching systemd services. See #987989 and
|
||||||
# reversion of its changes.
|
# reversion of its changes.
|
||||||
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox plinth.service
|
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox \
|
||||||
|
plinth.service freedombox-privileged.socket
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@ -23,6 +24,8 @@ EXIT_PERM = 20
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
socket_path = '/run/freedombox/privileged.socket'
|
||||||
|
|
||||||
|
|
||||||
# An alias for 'str' to mark some strings as sensitive. Sensitive strings are
|
# An alias for 'str' to mark some strings as sensitive. Sensitive strings are
|
||||||
# not logged. Use 'type secret_str = str' when Python 3.11 support is no longer
|
# not logged. Use 'type secret_str = str' when Python 3.11 support is no longer
|
||||||
@ -70,10 +73,133 @@ def privileged(func):
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
module_name = _get_privileged_action_module_name(func)
|
module_name = _get_privileged_action_module_name(func)
|
||||||
action_name = func.__name__
|
action_name = func.__name__
|
||||||
|
return _run_privileged_method(func, module_name, action_name, args,
|
||||||
|
kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _run_privileged_method(func, module_name, action_name, args, kwargs):
|
||||||
|
"""Execute a privileged method either using a server or sudo."""
|
||||||
|
try:
|
||||||
|
return _run_privileged_method_on_server(func, module_name, action_name,
|
||||||
|
list(args), dict(kwargs))
|
||||||
|
except (
|
||||||
|
NotImplementedError, # For raw_output and run_as_user flags
|
||||||
|
FileNotFoundError, # When the .socket file is not present
|
||||||
|
ConnectionRefusedError, # When is daemon not running
|
||||||
|
ConnectionResetError # When daemon fails permission check
|
||||||
|
):
|
||||||
return _run_privileged_method_as_process(func, module_name,
|
return _run_privileged_method_as_process(func, module_name,
|
||||||
action_name, args, kwargs)
|
action_name, args, kwargs)
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
def _read_from_server(client_socket: socket.socket) -> bytes:
|
||||||
|
"""Read everything from a socket and return the data."""
|
||||||
|
response = b''
|
||||||
|
while True:
|
||||||
|
chunk = client_socket.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
response += chunk
|
||||||
|
|
||||||
|
return json.loads(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_to_server(request: dict) -> socket.socket:
|
||||||
|
"""Connect to the server and make a request."""
|
||||||
|
request_string = json.dumps(request)
|
||||||
|
client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
client_socket.connect(socket_path)
|
||||||
|
client_socket.sendall(request_string.encode('utf-8'))
|
||||||
|
# Close the write end of the socket signaling an EOF and no more data
|
||||||
|
# will be sent.
|
||||||
|
client_socket.shutdown(socket.SHUT_WR)
|
||||||
|
except Exception:
|
||||||
|
client_socket.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return client_socket
|
||||||
|
|
||||||
|
|
||||||
|
def _run_privileged_method_on_server(func, module_name, action_name, args,
|
||||||
|
kwargs):
|
||||||
|
"""Execute a privileged method using a server."""
|
||||||
|
run_as_user = kwargs.pop('_run_as_user', None)
|
||||||
|
run_in_background = kwargs.pop('_run_in_background', False)
|
||||||
|
raw_output = kwargs.pop('_raw_output', False)
|
||||||
|
log_error = kwargs.pop('_log_error', True)
|
||||||
|
|
||||||
|
if raw_output or run_as_user:
|
||||||
|
raise NotImplementedError('Not yet implemented')
|
||||||
|
|
||||||
|
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
|
||||||
|
run_in_background, is_server=True)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'module': module_name,
|
||||||
|
'action': action_name,
|
||||||
|
'args': args,
|
||||||
|
'kwargs': kwargs
|
||||||
|
}
|
||||||
|
client_socket = _request_to_server(request)
|
||||||
|
|
||||||
|
args = (func, module_name, action_name, args, kwargs, log_error,
|
||||||
|
client_socket)
|
||||||
|
if not run_in_background:
|
||||||
|
return _wait_for_server_response(*args)
|
||||||
|
|
||||||
|
read_thread = threading.Thread(target=_wait_for_server_response, args=args)
|
||||||
|
read_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_server_response(func, module_name, action_name, args, kwargs,
|
||||||
|
log_error, client_socket):
|
||||||
|
"""Wait for the server to respond and process the response."""
|
||||||
|
try:
|
||||||
|
return_value = _read_from_server(client_socket)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error('Error decoding action return value %s..%s(*%s, **%s)',
|
||||||
|
module_name, action_name, args, kwargs)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
client_socket.close()
|
||||||
|
|
||||||
|
if return_value['result'] == 'success':
|
||||||
|
return return_value['return']
|
||||||
|
|
||||||
|
module = importlib.import_module(return_value['exception']['module'])
|
||||||
|
exception_class = getattr(module, return_value['exception']['name'])
|
||||||
|
exception = exception_class(*return_value['exception']['args'])
|
||||||
|
exception.stdout = b''
|
||||||
|
exception.stderr = b''
|
||||||
|
|
||||||
|
def _get_html_message():
|
||||||
|
"""Return an HTML format error that can be shown in messages."""
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
formatted_args = _format_args(func, args, kwargs)
|
||||||
|
exception_args, stdout, stderr, traceback = _format_error(
|
||||||
|
exception, return_value)
|
||||||
|
return format_html('Error running action: {}..{}({}): {}({})\n{}{}{}',
|
||||||
|
module_name, action_name, formatted_args,
|
||||||
|
return_value['exception']['name'], exception_args,
|
||||||
|
stdout, stderr, traceback)
|
||||||
|
|
||||||
|
exception.get_html_message = _get_html_message
|
||||||
|
|
||||||
|
if log_error:
|
||||||
|
formatted_args = _format_args(func, args, kwargs)
|
||||||
|
exception_args, stdout, stderr, traceback = _format_error(
|
||||||
|
exception, return_value)
|
||||||
|
logger.error('Error running action %s..%s(%s): %s(%s)\n'
|
||||||
|
'%s%s%s', module_name, action_name, formatted_args,
|
||||||
|
return_value['exception']['name'], exception_args, stdout,
|
||||||
|
stderr, traceback)
|
||||||
|
|
||||||
|
raise exception
|
||||||
|
|
||||||
|
|
||||||
def _run_privileged_method_as_process(func, module_name, action_name, args,
|
def _run_privileged_method_as_process(func, module_name, action_name, args,
|
||||||
@ -113,7 +239,7 @@ def _run_privileged_method_as_process(func, module_name, action_name, args,
|
|||||||
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
|
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
|
||||||
|
|
||||||
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
|
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
|
||||||
run_in_background)
|
run_in_background, is_server=False)
|
||||||
|
|
||||||
proc = subprocess.Popen(command, **proc_kwargs)
|
proc = subprocess.Popen(command, **proc_kwargs)
|
||||||
os.close(write_fd)
|
os.close(write_fd)
|
||||||
@ -297,9 +423,13 @@ def _get_privileged_action_module_name(func):
|
|||||||
|
|
||||||
|
|
||||||
def _log_action(func, module_name, action_name, args, kwargs, run_as_user,
|
def _log_action(func, module_name, action_name, args, kwargs, run_as_user,
|
||||||
run_in_background):
|
run_in_background, is_server):
|
||||||
"""Log an action in a compact format."""
|
"""Log an action in a compact format."""
|
||||||
|
if is_server:
|
||||||
|
prompt = '»'
|
||||||
|
else:
|
||||||
prompt = f'({run_as_user})$' if run_as_user else '#'
|
prompt = f'({run_as_user})$' if run_as_user else '#'
|
||||||
|
|
||||||
suffix = '&' if run_in_background else ''
|
suffix = '&' if run_in_background else ''
|
||||||
formatted_args = _format_args(func, args, kwargs)
|
formatted_args = _format_args(func, args, kwargs)
|
||||||
logger.info('%s %s..%s(%s) %s', prompt, module_name, action_name,
|
logger.info('%s %s..%s(%s) %s', prompt, module_name, action_name,
|
||||||
@ -359,6 +489,54 @@ def privileged_main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def privileged_handle_json_request(request_string: str) -> str:
|
||||||
|
"""Parse arguments for the program spawned as a privileged action."""
|
||||||
|
|
||||||
|
def _parse_request() -> dict:
|
||||||
|
"""Return a JSON parsed and validated request."""
|
||||||
|
try:
|
||||||
|
request = json.loads(request_string)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise SyntaxError('Invalid JSON in request')
|
||||||
|
|
||||||
|
required_parameters = [('module', str), ('action', str),
|
||||||
|
('args', list), ('kwargs', dict)]
|
||||||
|
|
||||||
|
for parameter, expected_type in required_parameters:
|
||||||
|
if parameter not in request:
|
||||||
|
raise TypeError(f'Missing required parameter "{parameter}"')
|
||||||
|
if not isinstance(request[parameter], expected_type):
|
||||||
|
raise TypeError(f'Parameter "{parameter}" must be of type'
|
||||||
|
f'{expected_type.__name__}')
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = _parse_request()
|
||||||
|
logger.info('Received request for %s..%s(..)', request['module'],
|
||||||
|
request['action'])
|
||||||
|
arguments = {'args': request['args'], 'kwargs': request['kwargs']}
|
||||||
|
return_value = _privileged_call(request['module'], request['action'],
|
||||||
|
arguments)
|
||||||
|
except (PermissionError, SyntaxError, TypeError, Exception) as exception:
|
||||||
|
if isinstance(exception, (PermissionError, SyntaxError, TypeError)):
|
||||||
|
logger.error(exception.args[0])
|
||||||
|
else:
|
||||||
|
logger.exception(exception)
|
||||||
|
|
||||||
|
return_value = {
|
||||||
|
'result': 'exception',
|
||||||
|
'exception': {
|
||||||
|
'module': type(exception).__module__,
|
||||||
|
'name': type(exception).__name__,
|
||||||
|
'args': exception.args,
|
||||||
|
'traceback': traceback.format_tb(exception.__traceback__)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(return_value, cls=JSONEncoder)
|
||||||
|
|
||||||
|
|
||||||
def _privileged_call(module_name, action_name, arguments):
|
def _privileged_call(module_name, action_name, arguments):
|
||||||
"""Import the module and run action as superuser"""
|
"""Import the module and run action as superuser"""
|
||||||
if '.' in module_name:
|
if '.' in module_name:
|
||||||
@ -368,7 +546,10 @@ def _privileged_call(module_name, action_name, arguments):
|
|||||||
if module_name == 'plinth':
|
if module_name == 'plinth':
|
||||||
import_path = 'plinth'
|
import_path = 'plinth'
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
import_path = module_loader.get_module_import_path(module_name)
|
import_path = module_loader.get_module_import_path(module_name)
|
||||||
|
except FileNotFoundError as exception:
|
||||||
|
raise SyntaxError('Specified module not found') from exception
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(import_path + '.privileged')
|
module = importlib.import_module(import_path + '.privileged')
|
||||||
|
|||||||
@ -596,6 +596,9 @@ class EnableState(LeaderComponent):
|
|||||||
|
|
||||||
def apps_init():
|
def apps_init():
|
||||||
"""Create apps by constructing them with components."""
|
"""Create apps by constructing them with components."""
|
||||||
|
if App.list():
|
||||||
|
return # Apps have already been initialized
|
||||||
|
|
||||||
from . import module_loader # noqa # Avoid circular import
|
from . import module_loader # noqa # Avoid circular import
|
||||||
for module_name, module in module_loader.loaded_modules.items():
|
for module_name, module in module_loader.loaded_modules.items():
|
||||||
_initialize_module(module_name, module)
|
_initialize_module(module_name, module)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ pytest configuration for all tests.
|
|||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -109,6 +110,17 @@ def fixture_needs_sudo():
|
|||||||
pytest.skip('Needs sudo command installed.')
|
pytest.skip('Needs sudo command installed.')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name='no_privileged_server', scope='module')
|
||||||
|
def fixture_no_privileged__server():
|
||||||
|
"""Don't setup for and run privileged methods on server.
|
||||||
|
|
||||||
|
Tests on using privileged daemon are not yet implemented.
|
||||||
|
"""
|
||||||
|
with patch('plinth.actions._run_privileged_method_on_server') as mock:
|
||||||
|
mock.side_effect = NotImplementedError
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def splinter_selenium_implicit_wait():
|
def splinter_selenium_implicit_wait():
|
||||||
"""Disable implicit waiting."""
|
"""Disable implicit waiting."""
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import importlib
|
|||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import types
|
||||||
|
|
||||||
import django
|
import django
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ from plinth.signals import pre_module_loading
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
loaded_modules = dict()
|
loaded_modules: dict[str, types.ModuleType] = dict()
|
||||||
_modules_to_load = None
|
_modules_to_load = None
|
||||||
|
|
||||||
|
|
||||||
@ -31,6 +32,9 @@ def load_modules():
|
|||||||
Read names of enabled modules in modules/enabled directory and
|
Read names of enabled modules in modules/enabled directory and
|
||||||
import them from modules directory.
|
import them from modules directory.
|
||||||
"""
|
"""
|
||||||
|
if loaded_modules:
|
||||||
|
return # Modules have already been loaded
|
||||||
|
|
||||||
pre_module_loading.send_robust(sender="module_loader")
|
pre_module_loading.send_robust(sender="module_loader")
|
||||||
for module_import_path in get_modules_to_load():
|
for module_import_path in get_modules_to_load():
|
||||||
module_name = module_import_path.split('.')[-1]
|
module_name = module_import_path.split('.')[-1]
|
||||||
|
|||||||
@ -15,7 +15,8 @@ from plinth.modules.backups import privileged
|
|||||||
from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
|
from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
|
||||||
from plinth.tests import config as test_config
|
from plinth.tests import config as test_config
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg')
|
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg',
|
||||||
|
'no_privileged_server')
|
||||||
|
|
||||||
# try to access a non-existing url and a URL that exists but does not
|
# try to access a non-existing url and a URL that exists but does not
|
||||||
# grant access
|
# grant access
|
||||||
|
|||||||
@ -194,8 +194,11 @@ class Packages(app_module.FollowerComponent):
|
|||||||
# Ensure package list is update-to-date before looking at dependencies.
|
# Ensure package list is update-to-date before looking at dependencies.
|
||||||
refresh_package_lists()
|
refresh_package_lists()
|
||||||
|
|
||||||
# List of packages to purge from the system
|
# List of packages to purge from the system. Be resilient to a package
|
||||||
packages = self.get_actual_packages()
|
# not being found in apt's cache by not using get_actual_packages(). If
|
||||||
|
# a package expression is listed as (package1 | package2) then try to
|
||||||
|
# remove both packages.
|
||||||
|
packages = self.possible_packages
|
||||||
logger.info('App\'s list of packages to remove: %s', packages)
|
logger.info('App\'s list of packages to remove: %s', packages)
|
||||||
|
|
||||||
packages = self._filter_packages_to_keep(packages)
|
packages = self._filter_packages_to_keep(packages)
|
||||||
@ -304,7 +307,7 @@ class Packages(app_module.FollowerComponent):
|
|||||||
|
|
||||||
# Remove packages used by other installed apps
|
# Remove packages used by other installed apps
|
||||||
for component in app.get_components_of_type(Packages):
|
for component in app.get_components_of_type(Packages):
|
||||||
keep_packages |= set(component.get_actual_packages())
|
keep_packages |= set(component.possible_packages)
|
||||||
|
|
||||||
# Get list of all the dependencies of packages to keep.
|
# Get list of all the dependencies of packages to keep.
|
||||||
keep_packages_with_deps: set[str] = set()
|
keep_packages_with_deps: set[str] = set()
|
||||||
|
|||||||
248
plinth/privileged_daemon.py
Normal file
248
plinth/privileged_daemon.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""The main method for a daemon that runs privileged methods."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import pwd
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import systemd.daemon
|
||||||
|
|
||||||
|
from . import __version__, actions
|
||||||
|
from . import app as app_module
|
||||||
|
from . import log, module_loader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
address = pathlib.Path('/run/freedombox/privileged.socket')
|
||||||
|
|
||||||
|
FREEDOMBOX_PROCESS_USER = 'plinth'
|
||||||
|
|
||||||
|
MAX_REQUEST_LENGTH = 1_000_000
|
||||||
|
|
||||||
|
idle_shutdown_time: int | None = 5 * 60 # 5 minutes
|
||||||
|
|
||||||
|
freedombox_develop = False
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(socketserver.StreamRequestHandler):
|
||||||
|
"""Handle a single streaming request.
|
||||||
|
|
||||||
|
It is instantiated once per connection to the server. The overridden
|
||||||
|
handle() method implements communication with the newly connected client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _read_request(self) -> str:
|
||||||
|
"""Return a single request read from the client."""
|
||||||
|
request_data = self.rfile.read(MAX_REQUEST_LENGTH + 1)
|
||||||
|
if len(request_data) > MAX_REQUEST_LENGTH:
|
||||||
|
raise ValueError('Request too large')
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = request_data.decode('utf-8')
|
||||||
|
except UnicodeError:
|
||||||
|
raise ValueError('Invalid Unicode in request')
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
def _write_response(self, response: str):
|
||||||
|
"""Write a single response to the client."""
|
||||||
|
self.wfile.write(response.encode('utf-8'))
|
||||||
|
|
||||||
|
def handle(self) -> None:
|
||||||
|
"""Handle a new connection from a client."""
|
||||||
|
try:
|
||||||
|
request = self._read_request()
|
||||||
|
response_string = actions.privileged_handle_json_request(request)
|
||||||
|
except Exception as exception:
|
||||||
|
logger.exception('Error running privileged request: %s', exception)
|
||||||
|
response = {
|
||||||
|
'result': 'exception',
|
||||||
|
'exception': {
|
||||||
|
'module': type(exception).__module__,
|
||||||
|
'name': type(exception).__name__,
|
||||||
|
'args': exception.args,
|
||||||
|
'traceback': traceback.format_tb(exception.__traceback__)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response_string = json.dumps(response)
|
||||||
|
|
||||||
|
self._write_response(response_string)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingUnixStreamServer):
|
||||||
|
"""Server to handle privileged request.
|
||||||
|
|
||||||
|
Requests from any process with UID other than from FreedomBox daemon user
|
||||||
|
or root will be denied.
|
||||||
|
|
||||||
|
If the server does not receive a request for idle_shutdown_time seconds and
|
||||||
|
no requests are being processed, then serve_forever() will raise
|
||||||
|
TimeoutError (so that the program can catch it and exit).
|
||||||
|
|
||||||
|
If the daemon is spawned by systemd socket activation, then systemd
|
||||||
|
provided socket is re-used (no bind() and listen() calls are made on it)
|
||||||
|
and it will not be closed after the server is shutdown. If a daemon is
|
||||||
|
spawned without socket activation, unix socket file is created with
|
||||||
|
appropriate permissions after the parent directory is created. socket
|
||||||
|
object is created, bind() and listen() calls will be made on the socket.
|
||||||
|
When server is shutdown, close() is called on the socket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, address, *args, **kwargs):
|
||||||
|
"""Initialize the server."""
|
||||||
|
# Retrieve FreedomBox process UID
|
||||||
|
user_info = pwd.getpwnam(FREEDOMBOX_PROCESS_USER)
|
||||||
|
self.allowed_peer_uids = [0, user_info.pw_uid]
|
||||||
|
|
||||||
|
# Used to auto-shutdown service
|
||||||
|
self.last_request_time = time.time()
|
||||||
|
|
||||||
|
self.listen_fd = self._get_listen_fd()
|
||||||
|
if self.listen_fd:
|
||||||
|
logger.info('systemd socket activated.')
|
||||||
|
self.socket = socket.fromfd(self.listen_fd, socket.AF_UNIX,
|
||||||
|
socket.SOCK_STREAM)
|
||||||
|
self.server_address = self.socket.getsockname()
|
||||||
|
super(socketserver.TCPServer,
|
||||||
|
self).__init__(address, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
logger.info('systemd service activated.')
|
||||||
|
address_path = pathlib.Path(address)
|
||||||
|
address_path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
|
address_path.unlink(missing_ok=True)
|
||||||
|
super().__init__(address, *args, **kwargs)
|
||||||
|
address_path.chmod(0o666) # All users are allowed to connect
|
||||||
|
|
||||||
|
def server_bind(self):
|
||||||
|
"""Called by constructor to activate the server.
|
||||||
|
|
||||||
|
Do nothing if socket-activated by systemd.
|
||||||
|
"""
|
||||||
|
if not self.listen_fd:
|
||||||
|
super().server_bind()
|
||||||
|
|
||||||
|
def server_activate(self):
|
||||||
|
"""Called by constructor to activate the server.
|
||||||
|
|
||||||
|
Do nothing if socket-activated by systemd.
|
||||||
|
"""
|
||||||
|
if not self.listen_fd:
|
||||||
|
super().server_activate()
|
||||||
|
|
||||||
|
def server_close(self):
|
||||||
|
"""Called to clean-up the server.
|
||||||
|
|
||||||
|
Don't close the socket if socket-activated by systemd.
|
||||||
|
"""
|
||||||
|
if not self.listen_fd:
|
||||||
|
# Not called with systemd socket activation. We are responsible for
|
||||||
|
# cleaning up the socket file we created.
|
||||||
|
super().server_close()
|
||||||
|
pathlib.Path(str(self.server_address)).unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
# Don't close the socket, systemd is still using it for next
|
||||||
|
# invocation.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def service_actions(self):
|
||||||
|
"""Called from serve_forever() loop. Shutdown service if unused."""
|
||||||
|
super().service_actions()
|
||||||
|
|
||||||
|
if idle_shutdown_time is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if time.time() - self.last_request_time < idle_shutdown_time:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(self._threads, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
if any(thread.is_alive() for thread in self._threads):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Raise an exception in the serve_forever() loop.
|
||||||
|
raise TimeoutError()
|
||||||
|
|
||||||
|
def _get_listen_fd(self):
|
||||||
|
"""Return the listening socket from systemd socket activation."""
|
||||||
|
listen_fds = systemd.daemon.listen_fds(unset_environment=True)
|
||||||
|
if len(listen_fds) == 0:
|
||||||
|
# Activated without socket activation.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(listen_fds) > 1:
|
||||||
|
# Activated with multiple listening sockets. We didn't configure
|
||||||
|
# our .socket unit like this. This is unexpected.
|
||||||
|
return None
|
||||||
|
|
||||||
|
listen_fd = listen_fds[0]
|
||||||
|
if not systemd.daemon.is_socket_unix(listen_fd, socket.SOCK_STREAM,
|
||||||
|
listening=1):
|
||||||
|
# Socket is not a AF_UNIX socket, it is not SOCK_STREAM socket, or
|
||||||
|
# listen() has not been called on it. This is unexpected.
|
||||||
|
return None
|
||||||
|
|
||||||
|
return listen_fd
|
||||||
|
|
||||||
|
def verify_request(self, request, client_address) -> bool:
|
||||||
|
"""Return False if the request must be denied."""
|
||||||
|
creds = request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||||
|
struct.calcsize('3i'))
|
||||||
|
_, uid, _ = struct.unpack('3i', creds)
|
||||||
|
if uid not in self.allowed_peer_uids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.last_request_time = time.time()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Start the server, listen on socket, and serve forever."""
|
||||||
|
global freedombox_develop, idle_shutdown_time
|
||||||
|
|
||||||
|
log.action_init()
|
||||||
|
|
||||||
|
logger.info('FreedomBox privileged daemon: %s', __version__)
|
||||||
|
|
||||||
|
if os.getenv('FREEDOMBOX_DEVELOP', '') == '1':
|
||||||
|
freedombox_develop = True
|
||||||
|
idle_shutdown_time = 5
|
||||||
|
logger.info('Running development mode, idle shutdown time = %ss',
|
||||||
|
idle_shutdown_time)
|
||||||
|
|
||||||
|
# When invoked as a systemd service, don't perform automatic idle shutdown.
|
||||||
|
if not systemd.daemon.listen_fds(unset_environment=False):
|
||||||
|
idle_shutdown_time = None
|
||||||
|
|
||||||
|
module_loader.load_modules()
|
||||||
|
app_module.apps_init()
|
||||||
|
|
||||||
|
with Server(str(address), RequestHandler) as server:
|
||||||
|
# systemd will wait until notification to proceed with other processes.
|
||||||
|
# We have service Type=notify.
|
||||||
|
systemd.daemon.notify('READY=1')
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except TimeoutError:
|
||||||
|
logger.info('FreedomBox privileged daemon exiting on idle.')
|
||||||
|
except Exception as exception:
|
||||||
|
logger.exception(
|
||||||
|
'FreedomBox privileged daemon exiting on error - %s',
|
||||||
|
exception)
|
||||||
|
sys.exit(-1)
|
||||||
|
else:
|
||||||
|
logger.info('FreedomBox privileged daemon exiting.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -20,6 +20,8 @@ from plinth.actions import privileged, secret_str
|
|||||||
|
|
||||||
actions_name = 'actions'
|
actions_name = 'actions'
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures('no_privileged_server')
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name='popen')
|
@pytest.fixture(name='popen')
|
||||||
def fixture_popen():
|
def fixture_popen():
|
||||||
|
|||||||
@ -31,7 +31,7 @@ from plinth.modules.firewall.components import get_port_forwarding_info
|
|||||||
from plinth.package import Packages
|
from plinth.package import Packages
|
||||||
from plinth.translation import get_language_from_request, set_language
|
from plinth.translation import get_language_from_request, set_language
|
||||||
|
|
||||||
from . import forms, frontpage, operation, package, setup
|
from . import forms, frontpage, operation, setup
|
||||||
|
|
||||||
REDIRECT_FIELD_NAME = 'next'
|
REDIRECT_FIELD_NAME = 'next'
|
||||||
|
|
||||||
@ -537,11 +537,6 @@ class SetupView(TemplateView):
|
|||||||
response.status_code = 303
|
response.status_code = 303
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if 'refresh-packages' in request.POST:
|
|
||||||
# Refresh apt package lists
|
|
||||||
package.refresh_package_lists()
|
|
||||||
return self.render_to_response(self.get_context_data())
|
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -103,6 +103,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
plinth = "plinth.__main__:main"
|
plinth = "plinth.__main__:main"
|
||||||
|
freedombox-privileged = "plinth.privileged_daemon:main"
|
||||||
|
|
||||||
[tool.setuptools.dynamic]
|
[tool.setuptools.dynamic]
|
||||||
version = {attr = "plinth.__version__"}
|
version = {attr = "plinth.__version__"}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user