Compare commits

..

No commits in common. "cf3bc4aae15cea1fc9d053c79afa4b30404c6957" and "4220511eb76762864a74f805721f14869b368a13" have entirely different histories.

16 changed files with 18 additions and 529 deletions

View File

@ -98,7 +98,6 @@ install:
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/COPYING.md && \
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/direct_url.json && \
$(INSTALL) -D -t $(BIN_DIR) bin/plinth
$(INSTALL) -D -t $(BIN_DIR) bin/freedombox-privileged
# Actions
$(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions
@ -151,13 +150,6 @@ clean:
rm -rf Plinth.egg-info
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)
provision-dev:
# Install newer build dependencies if any
@ -167,19 +159,9 @@ provision-dev:
# Install latest code over .deb
$(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
-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
-killall -9 unattended-upgr

View File

@ -1,6 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
import plinth.privileged_daemon
plinth.privileged_daemon.main()

View File

@ -1,21 +0,0 @@
# 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

View File

@ -1,16 +0,0 @@
# 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
View File

@ -50,6 +50,7 @@ Build-Depends:
python3-requests,
python3-ruamel.yaml,
python3-setuptools,
python3-setuptools-git,
python3-systemd,
python3-typeshed,
python3-yaml,

3
debian/rules vendored
View File

@ -34,5 +34,4 @@ override_dh_installsystemd:
# (as of debhelper 13.5.2) that still has hardcoded search path of
# /lib/systemd/system for searching systemd services. See #987989 and
# reversion of its changes.
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox \
plinth.service freedombox-privileged.socket
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox plinth.service

View File

@ -9,7 +9,6 @@ import json
import logging
import os
import pathlib
import socket
import subprocess
import sys
import threading
@ -24,8 +23,6 @@ EXIT_PERM = 20
logger = logging.getLogger(__name__)
socket_path = '/run/freedombox/privileged.socket'
# 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
@ -73,133 +70,10 @@ def privileged(func):
def wrapper(*args, **kwargs):
module_name = _get_privileged_action_module_name(func)
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,
action_name, args, kwargs)
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
return wrapper
def _run_privileged_method_as_process(func, module_name, action_name, args,
@ -239,7 +113,7 @@ def _run_privileged_method_as_process(func, module_name, action_name, args,
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
run_in_background, is_server=False)
run_in_background)
proc = subprocess.Popen(command, **proc_kwargs)
os.close(write_fd)
@ -423,13 +297,9 @@ def _get_privileged_action_module_name(func):
def _log_action(func, module_name, action_name, args, kwargs, run_as_user,
run_in_background, is_server):
run_in_background):
"""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 ''
formatted_args = _format_args(func, args, kwargs)
logger.info('%s %s..%s(%s) %s', prompt, module_name, action_name,
@ -489,54 +359,6 @@ def privileged_main():
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):
"""Import the module and run action as superuser"""
if '.' in module_name:
@ -546,10 +368,7 @@ def _privileged_call(module_name, action_name, arguments):
if module_name == 'plinth':
import_path = 'plinth'
else:
try:
import_path = module_loader.get_module_import_path(module_name)
except FileNotFoundError as exception:
raise SyntaxError('Specified module not found') from exception
import_path = module_loader.get_module_import_path(module_name)
try:
module = importlib.import_module(import_path + '.privileged')

View File

@ -596,9 +596,6 @@ class EnableState(LeaderComponent):
def apps_init():
"""Create apps by constructing them with components."""
if App.list():
return # Apps have already been initialized
from . import module_loader # noqa # Avoid circular import
for module_name, module in module_loader.loaded_modules.items():
_initialize_module(module_name, module)

View File

@ -6,7 +6,6 @@ pytest configuration for all tests.
import importlib
import os
import pathlib
from unittest.mock import patch
import pytest
@ -110,17 +109,6 @@ def fixture_needs_sudo():
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')
def splinter_selenium_implicit_wait():
"""Disable implicit waiting."""

View File

@ -7,7 +7,6 @@ import importlib
import logging
import pathlib
import re
import types
import django
@ -16,7 +15,7 @@ from plinth.signals import pre_module_loading
logger = logging.getLogger(__name__)
loaded_modules: dict[str, types.ModuleType] = dict()
loaded_modules = dict()
_modules_to_load = None
@ -32,9 +31,6 @@ def load_modules():
Read names of enabled modules in modules/enabled directory and
import them from modules directory.
"""
if loaded_modules:
return # Modules have already been loaded
pre_module_loading.send_robust(sender="module_loader")
for module_import_path in get_modules_to_load():
module_name = module_import_path.split('.')[-1]

View File

@ -15,8 +15,7 @@ from plinth.modules.backups import privileged
from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
from plinth.tests import config as test_config
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg',
'no_privileged_server')
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg')
# try to access a non-existing url and a URL that exists but does not
# grant access

View File

@ -194,11 +194,8 @@ class Packages(app_module.FollowerComponent):
# Ensure package list is update-to-date before looking at dependencies.
refresh_package_lists()
# List of packages to purge from the system. Be resilient to a package
# 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
# List of packages to purge from the system
packages = self.get_actual_packages()
logger.info('App\'s list of packages to remove: %s', packages)
packages = self._filter_packages_to_keep(packages)
@ -307,7 +304,7 @@ class Packages(app_module.FollowerComponent):
# Remove packages used by other installed apps
for component in app.get_components_of_type(Packages):
keep_packages |= set(component.possible_packages)
keep_packages |= set(component.get_actual_packages())
# Get list of all the dependencies of packages to keep.
keep_packages_with_deps: set[str] = set()

View File

@ -1,248 +0,0 @@
# 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()

View File

@ -20,8 +20,6 @@ from plinth.actions import privileged, secret_str
actions_name = 'actions'
pytestmark = pytest.mark.usefixtures('no_privileged_server')
@pytest.fixture(name='popen')
def fixture_popen():

View File

@ -31,7 +31,7 @@ from plinth.modules.firewall.components import get_port_forwarding_info
from plinth.package import Packages
from plinth.translation import get_language_from_request, set_language
from . import forms, frontpage, operation, setup
from . import forms, frontpage, operation, package, setup
REDIRECT_FIELD_NAME = 'next'
@ -537,6 +537,11 @@ class SetupView(TemplateView):
response.status_code = 303
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)
@staticmethod

View File

@ -103,7 +103,6 @@ build-backend = "setuptools.build_meta"
[project.scripts]
plinth = "plinth.__main__:main"
freedombox-privileged = "plinth.privileged_daemon:main"
[tool.setuptools.dynamic]
version = {attr = "plinth.__version__"}