FreedomBox/plinth/privileged_daemon.py
Sunil Mohan Adapa 0fdf59b9f0
privileged_daemon: Implement handling termination signal
- And gracefully terminate the process after finishing the current requests
underway.

Tests:

- Trigger a long operation such as an app installation. While the operation is
underway, run 'systemctl stop freedombox-privilved.service'. Journal will show
that the SIGTERM is handled and shutdown is more or less immediately complete.
However, the whole process will wait until the ongoing request is complete and
then exit.

- During the wait period, no new requests are accepted as experienced with
'freedombox-cmd plinth is_package_manager_busy --no-args' command.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:44 +03:00

322 lines
11 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""The main method for a daemon that runs privileged methods."""
import argparse
import io
import json
import logging
import os
import pathlib
import pwd
import signal
import socket
import socketserver
import struct
import sys
import threading
import time
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
_server = None
idle_shutdown_time: int | None = 5 * 60 # 5 minutes
freedombox_develop = False
EXIT_SYNTAX = 10
EXIT_PERM = 20
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 | io.BufferedReader):
"""Write a single response to the client."""
if isinstance(response, str):
self.wfile.write(response.encode('utf-8'))
else:
for chunk in response:
self.wfile.write(chunk)
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 = actions.get_return_value_from_exception(exception)
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 client_main() -> None:
"""Parse arguments for the client for privileged daemon."""
log.action_init(console=True)
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('--no-args', default=False, action='store_true',
help='Do not read arguments from stdin')
args = parser.parse_args()
try:
try:
arguments: dict = {'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 = actions.run_privileged_method(None, args.module,
args.action,
arguments['args'],
arguments['kwargs'])
print(json.dumps(return_value, cls=actions.JSONEncoder))
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 _on_sigterm(signal_number: int, frame) -> None:
"""Handle SIGTERM signal. Issue server shutdown."""
threading.Thread(target=_shutdown_server).start()
def _shutdown_server() -> None:
"""Issue a shutdown request to the server.
This must be run in a thread separate from the server.serve_forever()
otherwise it will deadlock waiting for the shutdown to complete.
"""
global _server
logger.info('SIGTERM received, shutting down the server.')
if _server:
_server.shutdown()
logger.info('Shutdown complete, some requests may be running.')
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
signal.signal(signal.SIGTERM, _on_sigterm)
module_loader.load_modules()
app_module.apps_init()
with Server(str(address), RequestHandler) as server:
global _server
_server = server # Reference needed to shutdown the 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.')
# Exit the context manager. This calls server.close() which waits on
# all pending request threads to complete.
logger.info('All requested completed. Exit.')
if __name__ == '__main__':
main()