From 213d0330fd7eb179ab51ddec33e51ee9ac22471c Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Wed, 25 Jun 2025 11:53:22 -0700 Subject: [PATCH] actions: Call to a privileged server if it is available - Instead of running the command using sudo. If the server is not reachable, run the privileged command using sudo. Tests: - Typical privileged calls are made to server as evidenced by the network emoji icon in the log. - Some actions such as creating gitweb repository or downloading a backup archive happen via sudo instead of privileged daemon. - When a call is made to privileged daemon the log message is show just like a sudo call. - If the daemon is not running and can't be started, the calls are made to sudo. - If the daemon is rejects connections, then calls are automatically made to sudo. - When cloning a gitweb repository, the operation is immediately returned and task runs in background. Other tasks as waited upon until they are finished. Introducing a sleep in privileged method leads to increased page load time. - When server sends non-JSON response, a decode error is printed and exception is raised. - When a typical privileged call is made, the return value as expected. - When a typical privileged call raises exception, a nice HTML exception is shown in the UI. stdout/stderr outputs are not shown. Error is also logged on the console as expected but without stdout/stderr. Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- plinth/actions.py | 128 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/plinth/actions.py b/plinth/actions.py index 352397546..095ccc4f9 100644 --- a/plinth/actions.py +++ b/plinth/actions.py @@ -9,6 +9,7 @@ import json import logging import os import pathlib +import socket import subprocess import sys import threading @@ -23,6 +24,8 @@ 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 @@ -70,10 +73,133 @@ 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) - 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,