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 <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
This commit is contained in:
Sunil Mohan Adapa 2025-08-13 08:48:35 -07:00 committed by Joseph Nuthalapati
parent 143e4a00bd
commit a580437de2
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
11 changed files with 83 additions and 33 deletions

View File

@ -106,6 +106,7 @@ install:
# Actions # Actions
$(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions $(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions
$(INSTALL) -D -t $(BIN_DIR) bin/freedombox-cmd
# Static web server files # Static web server files
rm -rf $(STATIC_FILES_DIRECTORY) rm -rf $(STATIC_FILES_DIRECTORY)

6
bin/freedombox-cmd Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
import plinth.privileged_daemon
plinth.privileged_daemon.client_main()

View File

@ -225,7 +225,7 @@ mount -o remount /freedombox
if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]] if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]]
then then
echo "> In machine: Enable backports" echo "> In machine: Enable backports"
/freedombox/actions/actions upgrades activate_backports --no-args /freedombox/bin/freedombox-cmd upgrades activate_backports --no-args
fi fi
echo "> In machine: Upgrade packages" echo "> In machine: Upgrade packages"

View File

@ -83,7 +83,7 @@ def privileged(func):
def _run_privileged_method(func, module_name, action_name, args, kwargs): def _run_privileged_method(func, module_name, action_name, args, kwargs):
"""Execute a privileged method either using a server or sudo.""" """Execute a privileged method either using a server or sudo."""
try: try:
return _run_privileged_method_on_server(func, module_name, action_name, return run_privileged_method_on_server(func, module_name, action_name,
list(args), dict(kwargs)) list(args), dict(kwargs))
except ( except (
FileNotFoundError, # When the .socket file is not present FileNotFoundError, # When the .socket file is not present
@ -124,13 +124,14 @@ def _request_to_server(request: dict) -> socket.socket:
return client_socket return client_socket
def _run_privileged_method_on_server(func, module_name, action_name, args, def run_privileged_method_on_server(func, module_name, action_name, args,
kwargs): kwargs):
"""Execute a privileged method using a server.""" """Execute a privileged method using a server."""
run_in_background = kwargs.pop('_run_in_background', False) run_in_background = kwargs.pop('_run_in_background', False)
raw_output = kwargs.pop('_raw_output', False) raw_output = kwargs.pop('_raw_output', False)
log_error = kwargs.pop('_log_error', True) log_error = kwargs.pop('_log_error', True)
if func:
_log_action(func, module_name, action_name, args, kwargs, _log_action(func, module_name, action_name, args, kwargs,
run_in_background, is_server=True) run_in_background, is_server=True)
@ -201,9 +202,10 @@ def _wait_for_server_response(func, module_name, action_name, args, kwargs,
return_value['exception']['name'], exception_args, return_value['exception']['name'], exception_args,
stdout, stderr, traceback) stdout, stderr, traceback)
if func:
exception.get_html_message = _get_html_message exception.get_html_message = _get_html_message
if log_error: if log_error and func:
formatted_args = _format_args(func, args, kwargs) formatted_args = _format_args(func, args, kwargs)
exception_args, stdout, stderr, traceback = _format_error( exception_args, stdout, stderr, traceback = _format_error(
exception, return_value) exception, return_value)

View File

@ -117,7 +117,7 @@ def fixture_no_privileged__server():
Tests on using privileged daemon are not yet implemented. 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 mock.side_effect = NotImplementedError
yield yield

View File

@ -146,12 +146,12 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
# 'reconnect', 'ServerAliveInternal' and 'ServerAliveCountMax' allow the # 'reconnect', 'ServerAliveInternal' and 'ServerAliveCountMax' allow the
# client (FreedomBox) to keep control of the SSH connection even when the # client (FreedomBox) to keep control of the SSH connection even when the
# SSH server misbehaves. Without these options, other commands such as # SSH server misbehaves. Without these options, other commands such as
# '/usr/share/plinth/actions/actions storage usage_info --no-args', 'df', # 'freedombox-cmd storage usage_info --no-args', 'df', 'freedombox-cmd
# '/usr/share/plinth/actions/actions sshfs is_mounted --no-args', or # sshfs is_mounted --no-args', or 'mountpoint' might block indefinitely
# 'mountpoint' might block indefinitely (even when manually invoked from # (even when manually invoked from the command line). This situation has
# the command line). This situation has some lateral effects, causing major # some lateral effects, causing major system instability in the course of
# system instability in the course of ~11 days, and leaving the system in # ~11 days, and leaving the system in such state that the only solution is
# such state that the only solution is a reboot. # a reboot.
cmd = [ cmd = [
'sshfs', remote_path, mountpoint, '-o', 'sshfs', remote_path, mountpoint, '-o',
f'UserKnownHostsFile={user_known_hosts_file}', '-o', f'UserKnownHostsFile={user_known_hosts_file}', '-o',

View File

@ -247,7 +247,7 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]:
if snapshot_module.is_apt_snapshots_enabled(aug): if snapshot_module.is_apt_snapshots_enabled(aug):
logger.info('Disabling apt snapshots during dist upgrade...') logger.info('Disabling apt snapshots during dist upgrade...')
subprocess.run([ subprocess.run([
'/usr/share/plinth/actions/actions', '/usr/bin/freedombox-cmd',
'snapshot', 'snapshot',
'disable_apt_snapshot', 'disable_apt_snapshot',
], input='{"args": ["yes"], "kwargs": {}}'.encode(), check=True) ], input='{"args": ["yes"], "kwargs": {}}'.encode(), check=True)
@ -260,8 +260,7 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]:
if reenable: if reenable:
logger.info('Re-enabling apt snapshots...') logger.info('Re-enabling apt snapshots...')
subprocess.run([ subprocess.run([
'/usr/share/plinth/actions/actions', 'snapshot', '/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'
'disable_apt_snapshot'
], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True) ], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True)
else: else:
logger.info('Not re-enabling apt snapshots, as they were disabled ' logger.info('Not re-enabling apt snapshots, as they were disabled '
@ -388,8 +387,8 @@ def _trigger_on_complete():
subprocess.run([ subprocess.run([
'systemd-run', '--unit=freedombox-dist-upgrade-on-complete', 'systemd-run', '--unit=freedombox-dist-upgrade-on-complete',
'--description=Finish up upgrade to new stable Debian release', '--description=Finish up upgrade to new stable Debian release',
'/usr/share/plinth/actions/actions', 'upgrades', '/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete',
'dist_upgrade_on_complete', '--no-args' '--no-args'
], check=True) ], check=True)
@ -443,7 +442,7 @@ def start_service():
f'--property=BindPaths={temp_sources_list}:{sources_list}' f'--property=BindPaths={temp_sources_list}:{sources_list}'
] ]
subprocess.run(['systemd-run'] + args + [ subprocess.run(['systemd-run'] + args + [
'systemd-inhibit', '/usr/share/plinth/actions/actions', 'upgrades', 'systemd-inhibit', '/usr/bin/freedombox-cmd', 'upgrades',
'dist_upgrade', '--no-args' 'dist_upgrade', '--no-args'
], check=True) ], check=True)

View File

@ -266,17 +266,14 @@ def test_snapshot_run_and_disable(is_supported, is_apt_snapshots_enabled, run):
call(['snapper', 'create', '--description', 'before dist-upgrade'], call(['snapper', 'create', '--description', 'before dist-upgrade'],
check=True), check=True),
call([ call([
'/usr/share/plinth/actions/actions', 'snapshot', '/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'
'disable_apt_snapshot'
], input=b'{"args": ["yes"], "kwargs": {}}', check=True) ], input=b'{"args": ["yes"], "kwargs": {}}', check=True)
] ]
run.reset_mock() run.reset_mock()
assert run.call_args_list == [ assert run.call_args_list == [
call([ call(['/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'],
'/usr/share/plinth/actions/actions', 'snapshot', input=b'{"args": ["no"], "kwargs": {}}', check=True)
'disable_apt_snapshot'
], input=b'{"args": ["no"], "kwargs": {}}', check=True)
] ]
@ -419,8 +416,8 @@ def test_trigger_on_complete(run):
run.assert_called_with([ run.assert_called_with([
'systemd-run', '--unit=freedombox-dist-upgrade-on-complete', 'systemd-run', '--unit=freedombox-dist-upgrade-on-complete',
'--description=Finish up upgrade to new stable Debian release', '--description=Finish up upgrade to new stable Debian release',
'/usr/share/plinth/actions/actions', 'upgrades', '/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete',
'dist_upgrade_on_complete', '--no-args' '--no-args'
], check=True) ], check=True)

View File

@ -56,9 +56,9 @@
{% blocktrans trimmed %} {% blocktrans trimmed %}
Delete these accounts from command line and refresh the page to create 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 an account that is usable with {{ box_name }}. On the command line run
the command "echo '{"args": ["USERNAME", "PASSWORD"], "kwargs": {}}' | the command "echo '{"args": ["USERNAME", "AUTH_USER", "AUTH_PASSWORD"],
sudo /usr/share/plinth/actions/actions users remove_user". If an "kwargs": {}}' | sudo freedombox-cmd users remove_user". If an account
account is already usable with {{ box_name }}, skip this step. is already usable with {{ box_name }}, skip this step.
{% endblocktrans %} {% endblocktrans %}
</p> </p>

View File

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""The main method for a daemon that runs privileged methods.""" """The main method for a daemon that runs privileged methods."""
import argparse
import io import io
import json import json
import logging import logging
@ -32,6 +33,9 @@ idle_shutdown_time: int | None = 5 * 60 # 5 minutes
freedombox_develop = False freedombox_develop = False
EXIT_SYNTAX = 10
EXIT_PERM = 20
class RequestHandler(socketserver.StreamRequestHandler): class RequestHandler(socketserver.StreamRequestHandler):
"""Handle a single streaming request. """Handle a single streaming request.
@ -210,6 +214,46 @@ class Server(socketserver.ThreadingUnixStreamServer):
return True 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: def main() -> None:
"""Start the server, listen on socket, and serve forever.""" """Start the server, listen on socket, and serve forever."""
global freedombox_develop, idle_shutdown_time global freedombox_develop, idle_shutdown_time

View File

@ -104,6 +104,7 @@ build-backend = "setuptools.build_meta"
[project.scripts] [project.scripts]
plinth = "plinth.__main__:main" plinth = "plinth.__main__:main"
freedombox-privileged = "plinth.privileged_daemon:main" freedombox-privileged = "plinth.privileged_daemon:main"
freedombox-cmd = "plinth.privileged_daemon:client_main"
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = {attr = "plinth.__version__"} version = {attr = "plinth.__version__"}