From a580437de2bce368345a91a0c8a04e31e32b7a90 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Wed, 13 Aug 2025 08:48:35 -0700 Subject: [PATCH] privileged_daemon: Introduce a command line client for the API - Similar call signature as actions/actions. Tests: - Running make install places a binary file in /usr/bin/freedombox-cmd - Running 'freedombox-cmd upgrades get_log --no-args' works. - Running 'freedombox-cmd upgrades activate_backports --no-args' works. - Running 'freedombox-cmd storage usage_info --no-args' works. - Running 'freedombox-cmd sshfs is_mounted --no-args' works. - echo '{"args": ["USERNAME", "AUTH_USER", "AUTH_PASSWORD"], "kwargs": {}}' | sudo freedombox-cmd users remove_user. - Distribution upgrade from Bookworm to Trixie works. - Snapshots are disabled and re-enabled during upgrade. - /etc/apt/sources.list changes are completed on completion. - If a command fails, the return code is not 0. Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- Makefile | 1 + bin/freedombox-cmd | 6 +++ container | 2 +- plinth/actions.py | 18 ++++---- plinth/conftest.py | 2 +- plinth/modules/backups/privileged.py | 12 ++--- plinth/modules/upgrades/distupgrade.py | 11 +++-- .../upgrades/tests/test_distupgrade.py | 13 +++--- .../users/templates/users_firstboot.html | 6 +-- plinth/privileged_daemon.py | 44 +++++++++++++++++++ pyproject.toml | 1 + 11 files changed, 83 insertions(+), 33 deletions(-) create mode 100755 bin/freedombox-cmd diff --git a/Makefile b/Makefile index bc5cb22cf..54f697a96 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,7 @@ install: # Actions $(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions + $(INSTALL) -D -t $(BIN_DIR) bin/freedombox-cmd # Static web server files rm -rf $(STATIC_FILES_DIRECTORY) diff --git a/bin/freedombox-cmd b/bin/freedombox-cmd new file mode 100755 index 000000000..ce9c06093 --- /dev/null +++ b/bin/freedombox-cmd @@ -0,0 +1,6 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later + +import plinth.privileged_daemon + +plinth.privileged_daemon.client_main() diff --git a/container b/container index 5424d6a94..91630a321 100755 --- a/container +++ b/container @@ -225,7 +225,7 @@ mount -o remount /freedombox if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]] then echo "> In machine: Enable backports" - /freedombox/actions/actions upgrades activate_backports --no-args + /freedombox/bin/freedombox-cmd upgrades activate_backports --no-args fi echo "> In machine: Upgrade packages" diff --git a/plinth/actions.py b/plinth/actions.py index c36d67b99..394ae6fb9 100644 --- a/plinth/actions.py +++ b/plinth/actions.py @@ -83,8 +83,8 @@ def privileged(func): 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)) + return run_privileged_method_on_server(func, module_name, action_name, + list(args), dict(kwargs)) except ( FileNotFoundError, # When the .socket file is not present ConnectionRefusedError, # When is daemon not running @@ -124,15 +124,16 @@ def _request_to_server(request: dict) -> socket.socket: return client_socket -def _run_privileged_method_on_server(func, module_name, action_name, args, - kwargs): +def run_privileged_method_on_server(func, module_name, action_name, args, + kwargs): """Execute a privileged method using a server.""" run_in_background = kwargs.pop('_run_in_background', False) raw_output = kwargs.pop('_raw_output', False) log_error = kwargs.pop('_log_error', True) - _log_action(func, module_name, action_name, args, kwargs, - run_in_background, is_server=True) + if func: + _log_action(func, module_name, action_name, args, kwargs, + run_in_background, is_server=True) request = { 'module': module_name, @@ -201,9 +202,10 @@ def _wait_for_server_response(func, module_name, action_name, args, kwargs, return_value['exception']['name'], exception_args, stdout, stderr, traceback) - exception.get_html_message = _get_html_message + if func: + exception.get_html_message = _get_html_message - if log_error: + if log_error and func: formatted_args = _format_args(func, args, kwargs) exception_args, stdout, stderr, traceback = _format_error( exception, return_value) diff --git a/plinth/conftest.py b/plinth/conftest.py index 52d32e839..3c56a33fe 100644 --- a/plinth/conftest.py +++ b/plinth/conftest.py @@ -117,7 +117,7 @@ def fixture_no_privileged__server(): Tests on using privileged daemon are not yet implemented. """ - with patch('plinth.actions._run_privileged_method_on_server') as mock: + with patch('plinth.actions.run_privileged_method_on_server') as mock: mock.side_effect = NotImplementedError yield diff --git a/plinth/modules/backups/privileged.py b/plinth/modules/backups/privileged.py index 06cc2728e..c010c5f23 100644 --- a/plinth/modules/backups/privileged.py +++ b/plinth/modules/backups/privileged.py @@ -146,12 +146,12 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None, # 'reconnect', 'ServerAliveInternal' and 'ServerAliveCountMax' allow the # client (FreedomBox) to keep control of the SSH connection even when the # SSH server misbehaves. Without these options, other commands such as - # '/usr/share/plinth/actions/actions storage usage_info --no-args', 'df', - # '/usr/share/plinth/actions/actions sshfs is_mounted --no-args', or - # 'mountpoint' might block indefinitely (even when manually invoked from - # the command line). This situation has some lateral effects, causing major - # system instability in the course of ~11 days, and leaving the system in - # such state that the only solution is a reboot. + # 'freedombox-cmd storage usage_info --no-args', 'df', 'freedombox-cmd + # sshfs is_mounted --no-args', or 'mountpoint' might block indefinitely + # (even when manually invoked from the command line). This situation has + # some lateral effects, causing major system instability in the course of + # ~11 days, and leaving the system in such state that the only solution is + # a reboot. cmd = [ 'sshfs', remote_path, mountpoint, '-o', f'UserKnownHostsFile={user_known_hosts_file}', '-o', diff --git a/plinth/modules/upgrades/distupgrade.py b/plinth/modules/upgrades/distupgrade.py index f1feafe34..7bdb97d52 100644 --- a/plinth/modules/upgrades/distupgrade.py +++ b/plinth/modules/upgrades/distupgrade.py @@ -247,7 +247,7 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]: if snapshot_module.is_apt_snapshots_enabled(aug): logger.info('Disabling apt snapshots during dist upgrade...') subprocess.run([ - '/usr/share/plinth/actions/actions', + '/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot', ], input='{"args": ["yes"], "kwargs": {}}'.encode(), check=True) @@ -260,8 +260,7 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]: if reenable: logger.info('Re-enabling apt snapshots...') subprocess.run([ - '/usr/share/plinth/actions/actions', 'snapshot', - 'disable_apt_snapshot' + '/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot' ], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True) else: logger.info('Not re-enabling apt snapshots, as they were disabled ' @@ -388,8 +387,8 @@ def _trigger_on_complete(): subprocess.run([ 'systemd-run', '--unit=freedombox-dist-upgrade-on-complete', '--description=Finish up upgrade to new stable Debian release', - '/usr/share/plinth/actions/actions', 'upgrades', - 'dist_upgrade_on_complete', '--no-args' + '/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete', + '--no-args' ], check=True) @@ -443,7 +442,7 @@ def start_service(): f'--property=BindPaths={temp_sources_list}:{sources_list}' ] subprocess.run(['systemd-run'] + args + [ - 'systemd-inhibit', '/usr/share/plinth/actions/actions', 'upgrades', + 'systemd-inhibit', '/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade', '--no-args' ], check=True) diff --git a/plinth/modules/upgrades/tests/test_distupgrade.py b/plinth/modules/upgrades/tests/test_distupgrade.py index 52f3cfaff..adca37591 100644 --- a/plinth/modules/upgrades/tests/test_distupgrade.py +++ b/plinth/modules/upgrades/tests/test_distupgrade.py @@ -266,17 +266,14 @@ def test_snapshot_run_and_disable(is_supported, is_apt_snapshots_enabled, run): call(['snapper', 'create', '--description', 'before dist-upgrade'], check=True), call([ - '/usr/share/plinth/actions/actions', 'snapshot', - 'disable_apt_snapshot' + '/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot' ], input=b'{"args": ["yes"], "kwargs": {}}', check=True) ] run.reset_mock() assert run.call_args_list == [ - call([ - '/usr/share/plinth/actions/actions', 'snapshot', - 'disable_apt_snapshot' - ], input=b'{"args": ["no"], "kwargs": {}}', check=True) + call(['/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'], + input=b'{"args": ["no"], "kwargs": {}}', check=True) ] @@ -419,8 +416,8 @@ def test_trigger_on_complete(run): run.assert_called_with([ 'systemd-run', '--unit=freedombox-dist-upgrade-on-complete', '--description=Finish up upgrade to new stable Debian release', - '/usr/share/plinth/actions/actions', 'upgrades', - 'dist_upgrade_on_complete', '--no-args' + '/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete', + '--no-args' ], check=True) diff --git a/plinth/modules/users/templates/users_firstboot.html b/plinth/modules/users/templates/users_firstboot.html index 34eaded4a..5eae7ae3c 100644 --- a/plinth/modules/users/templates/users_firstboot.html +++ b/plinth/modules/users/templates/users_firstboot.html @@ -56,9 +56,9 @@ {% blocktrans trimmed %} Delete these accounts from command line and refresh the page to create an account that is usable with {{ box_name }}. On the command line run - the command "echo '{"args": ["USERNAME", "PASSWORD"], "kwargs": {}}' | - sudo /usr/share/plinth/actions/actions users remove_user". If an - account is already usable with {{ box_name }}, skip this step. + the command "echo '{"args": ["USERNAME", "AUTH_USER", "AUTH_PASSWORD"], + "kwargs": {}}' | sudo freedombox-cmd users remove_user". If an account + is already usable with {{ box_name }}, skip this step. {% endblocktrans %}

diff --git a/plinth/privileged_daemon.py b/plinth/privileged_daemon.py index d44543db0..1ff8ea630 100644 --- a/plinth/privileged_daemon.py +++ b/plinth/privileged_daemon.py @@ -1,6 +1,7 @@ # 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 @@ -32,6 +33,9 @@ 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. @@ -210,6 +214,46 @@ class Server(socketserver.ThreadingUnixStreamServer): return True +def client_main() -> None: + """Parse arguments for the client for privileged daemon.""" + log.action_init() + + 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 main() -> None: """Start the server, listen on socket, and serve forever.""" global freedombox_develop, idle_shutdown_time diff --git a/pyproject.toml b/pyproject.toml index 90b7c8a35..7c0cf690f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ build-backend = "setuptools.build_meta" [project.scripts] plinth = "plinth.__main__:main" freedombox-privileged = "plinth.privileged_daemon:main" +freedombox-cmd = "plinth.privileged_daemon:client_main" [tool.setuptools.dynamic] version = {attr = "plinth.__version__"}