privileged: Introduce a UNIX socket daemon for privileged calls

- This daemon will be faster than running actions using 'sudo' because the
actions sometimes load all the modules before certain safety checks can be
performed. The daemon will load just once for many privileged calls.

Tests:

- After daemon is started, systemd shows the status as 'activated'.

- When daemon is started using socket activation and requests are sent, the
  requests succeed.

- When daemon is started manually and requests are sent, the requests succeed.
  The socket file is created with root:root ownership and 0666 permissions.
  Parent directory is created if not exists. After the daemon exits, the socket
  file is removed. When daemon is started manually, automatic idle timeout exit
  does not happen.

- According to journalctl, server exists after 5 seconds. Proper log message is
  seen.

- Without development mode, server exists after 5 minutes of idle. Proper log
  message is seen.

- When a sleep is added in one of the actions and when the action is running,
  server does not exit. Server exits after the request is completed.

- When an error is raised in verify request, the server exits with proper error
  message. If the server exists with non-zero error code and is immediately
  restarted by systemd.

- Sending a sample request using nc from root user and plinth user works.

- Sending a sample request using nc from fbx user is rejected.

- If a non-unicode text is sent as request, the response is a valid error
  dictionary.

- If the request is larger than 1M, an 'request too large' error is thrown.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
This commit is contained in:
Sunil Mohan Adapa 2024-09-30 11:17:15 -07:00 committed by Joseph Nuthalapati
parent 72bcb93f56
commit c47a856e3e
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35

248
plinth/privileged_daemon.py Normal file
View 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()