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
$(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)

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 ]]
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"

View File

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

View File

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

View File

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

View File

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

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'],
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)

View File

@ -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 %}
</p>

View File

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

View File

@ -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__"}