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 <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
This commit is contained in:
Sunil Mohan Adapa 2025-06-25 11:53:22 -07:00 committed by Joseph Nuthalapati
parent 99c28b583f
commit 213d0330fd
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35

View File

@ -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,