Compare commits

...

15 Commits

Author SHA1 Message Date
Sunil Mohan Adapa
cf3bc4aae1
d/control: Drop dependency on python3-setuptools-git
Tests:

- Build a Debian package before and after the patch and notice that binary
packages have no differences when compared with diffoscope. Source packages show
only the change in the patch but no other change.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-08-16 14:16:08 -04:00
Joseph Nuthalapati
2f53c4dd39
actions:privileged: Fix flake8 errors
Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 23:04:16 +05:30
Sunil Mohan Adapa
e26a4b71eb
package: Uninstall packages even if they are not in apt cache
Tests:

- Install matrix-synapse app by adding 'unstable' in apt sources.list. Then
remove 'unstable' from apt sources.list. Then matrix-synapse package will no
longer be found in the apt's cache.

- Try to uninstall the package. Without patch, the process errors out. With
patch, uninstall completes successfully.

- While matrix-synapse app is installed and apt cache does not contain
matrix-synapse package, install and uninstall bepasty app. Without patch,
uninstall fails. With patch, uninstall succeeds.

- Install and uninstall minetest app. 3d armor mod package is successfully
installed.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 23:04:05 +05:30
Sunil Mohan Adapa
3f19c91007
views: Remove unused code related to refresh packages button
- This change was part of the original pull request !2661 but was missed in its
continuation !2677.

Tests:

- Installing an app works.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 23:03:55 +05:30
Sunil Mohan Adapa
80705b85af
actions: Raise a proper exception if privileged module is not found
Without the change FileNotFound exception is raised.

Tests:

- Send request using 'nc' to privileged daemon that has invalid 'module'
parameter. SyntaxError exception is raised.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:42:33 +05:30
Sunil Mohan Adapa
a70611a2e9
tests: Ensure that privileged daemon is not used during tests
- Fallback to sudo based privileged implementation. Privileged daemon tests are
still to be implemented.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:42:15 +05:30
Sunil Mohan Adapa
213d0330fd
actions: Call to a privileged server if it is available
- Instead of running the command using sudo. If the server is not reachable, run
the privileged command using sudo.

Tests:

- Typical privileged calls are made to server as evidenced by the network emoji
  icon in the log.

- Some actions such as creating gitweb repository or downloading a backup
  archive happen via sudo instead of privileged daemon.

- When a call is made to privileged daemon the log message is show just like a
  sudo call.

- If the daemon is not running and can't be started, the calls are made to sudo.

- If the daemon is rejects connections, then calls are automatically made to
  sudo.

- When cloning a gitweb repository, the operation is immediately returned and
  task runs in background. Other tasks as waited upon until they are finished.
  Introducing a sleep in privileged method leads to increased page load time.

- When server sends non-JSON response, a decode error is printed and exception
  is raised.

- When a typical privileged call is made, the return value as expected.

- When a typical privileged call raises exception, a nice HTML exception is
  shown in the UI. stdout/stderr outputs are not shown. Error is also logged on
  the console as expected but without stdout/stderr.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:41:52 +05:30
Sunil Mohan Adapa
99c28b583f
actions: Allow logging privileged calls to a server differently
Tests:

- When a call is made to privileged daemon the log shows network emoji instead
  of #.

- Log for unimplemented calls such as downloading backup images still shows # as
  they not sent to privileged daemon.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:41:39 +05:30
Sunil Mohan Adapa
847de4d570
Makefile: Developer setup for privileged daemon
Tests:

- When a new container image is provisioned, developer configuration is set on
  privileged daemon as seen with 'systemctl show freedombox-privileged.service'.
  freedombox-privileged.socket is enabled and running (socket is being listened
  on).

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:41:30 +05:30
Sunil Mohan Adapa
71a50e6d19
privileged: Add systemd units for socket activating the daemon
Tests:

- Daemon starts up with uid/gid set to root.

- Daemon does not run by default if a request is not received. Socket file is
  created with 666 permissions and root:root ownership. Socket file parent directory
  is created with 755 permissions and root:root ownership.

- Daemon starts if a request is sent to the socket using nc.

- If there an exception in daemon starting, then restart is done every second to
  5 seconds, forever.

- Build a Debian package.

  - Install it on fresh trixie Debian VM. Ensure that setup works and privileged
    daemon is auto-enabled.

  - Start a fresh trixie Debian VM and install freedombox from Debian repos.
    Upgrade to the built package. Privileged daemon works and is auto-enabled.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:41:20 +05:30
Sunil Mohan Adapa
a6089664eb
privileged: Add /bin file for privileged daemon
- So that it can be invoked easily from the command line and systemd service.

Tests:

- make install creates /usr/bin/freedombox-privileged daemon which can be run as
  root.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:41:12 +05:30
Sunil Mohan Adapa
c47a856e3e
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>
2025-08-16 22:40:54 +05:30
Sunil Mohan Adapa
72bcb93f56
actions: Add method to handle privileged JSON request to a server
Tests:

- When privileged daemon receives a non-JSON request, a proper error structure
  is returned with SyntaxError.

- When privileged daemon receives a request without 'module', 'action', 'args'
  or 'kwargs' parameters, a proper error structure is returned with TypeError.

- When privileged daemon receives a request for invalid 'module' or 'action', a
  proper error structure is returned with SyntaxError.

- When an exception is thrown in a privileged method, the error is properly
  returned in error structure and caller is shown all the proper details.

- Valid return values are sent when a privileged call is made.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>

- Refactor validation of fields in the JSON object.
- Throw distinct errors for missing field and wrong type.
Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>

Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:39:43 +05:30
Sunil Mohan Adapa
f2edc6ab2b
app: Don't load apps again
- Only effective once. Second call will skip loading apps.

- Helps with privileged daemon where actions might load apps repeatedly.

Tests:

- Diagnostics/enable/disable for apps bepasty, updates, config, security,
  nextcloud, homeassistant run fine.

- Install/uninstall for bepasty works fine.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:39:18 +05:30
Sunil Mohan Adapa
e100c89ecc
module_loader: Don't load modules again
- Only effective once. Second call will skip loading modules.

- Helps with privileged daemon where actions might load modules repeatedly.

Tests:

- Diagnostics/enable/disable for apps bepasty, updates, config, security,
  nextcloud, homeassistant run fine.
- Install/uninstall for bepasty works fine.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-08-16 22:39:04 +05:30
16 changed files with 529 additions and 18 deletions

View File

@ -98,6 +98,7 @@ install:
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/COPYING.md && \
rm -f $(DESTDIR)$${lib_dir}/plinth*.dist-info/direct_url.json && \
$(INSTALL) -D -t $(BIN_DIR) bin/plinth
$(INSTALL) -D -t $(BIN_DIR) bin/freedombox-privileged
# Actions
$(INSTALL) -D -t $(DESTDIR)/usr/share/plinth/actions actions/actions
@ -150,6 +151,13 @@ clean:
rm -rf Plinth.egg-info
find plinth/locale -name *.mo -delete
define DEVELOP_SERVICE_CONF
[Service]
Environment=FREEDOMBOX_DEVELOP=1
Environment=PYTHONPATH=/freedombox/
endef
export DEVELOP_SERVICE_CONF
# Run basic setup for a developer environment (VM or container)
provision-dev:
# Install newer build dependencies if any
@ -159,9 +167,19 @@ provision-dev:
# Install latest code over .deb
$(MAKE) build install
# Configure privileged daemon for development setup
mkdir -p /etc/systemd/system/freedombox-privileged.service.d/
echo "$$DEVELOP_SERVICE_CONF" > /etc/systemd/system/freedombox-privileged.service.d/develop.conf
# Reload newer systemd units, ignore failure
-systemctl daemon-reload
# Enable privileged daemon
-systemctl stop freedombox-privileged.service
-test -d /run/systemd/system && \
systemctl enable --now freedombox-privileged.socket
# Stop any ongoing upgrade, ignore failure
-killall -9 unattended-upgr

6
bin/freedombox-privileged 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.main()

View File

@ -0,0 +1,21 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
[Unit]
Description=FreedomBox Privileged Service
Documentation=https://wiki.debian.org/FreedomBox/
# Don't hit the start rate limiting.
StartLimitIntervalSec=0
[Service]
Type=notify
ExecStart=/usr/bin/freedombox-privileged
TimeoutSec=300s
User=root
Group=root
NotifyAccess=main
PrivateTmp=yes
Restart=on-failure
# Don't restart too fast
RestartSec=1
RestartSteps=3
RestartMaxDelaySec=5

View File

@ -0,0 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
[Unit]
Description=FreedomBox Privileged Service Socket
Documentation=https://wiki.debian.org/FreedomBox/
[Socket]
Accept=no
ListenStream=/run/freedombox/privileged.socket
SocketUser=root
SocketGroup=root
SocketMode=0666
DirectoryMode=755
[Install]
WantedBy=sockets.target

1
debian/control vendored
View File

@ -50,7 +50,6 @@ Build-Depends:
python3-requests,
python3-ruamel.yaml,
python3-setuptools,
python3-setuptools-git,
python3-systemd,
python3-typeshed,
python3-yaml,

3
debian/rules vendored
View File

@ -34,4 +34,5 @@ override_dh_installsystemd:
# (as of debhelper 13.5.2) that still has hardcoded search path of
# /lib/systemd/system for searching systemd services. See #987989 and
# reversion of its changes.
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox plinth.service
dh_installsystemd --tmpdir=debian/tmp/usr --package=freedombox \
plinth.service freedombox-privileged.socket

View File

@ -9,6 +9,7 @@ import json
import logging
import os
import pathlib
import socket
import subprocess
import sys
import threading
@ -23,6 +24,8 @@ EXIT_PERM = 20
logger = logging.getLogger(__name__)
socket_path = '/run/freedombox/privileged.socket'
# An alias for 'str' to mark some strings as sensitive. Sensitive strings are
# not logged. Use 'type secret_str = str' when Python 3.11 support is no longer
@ -70,10 +73,133 @@ def privileged(func):
def wrapper(*args, **kwargs):
module_name = _get_privileged_action_module_name(func)
action_name = func.__name__
return _run_privileged_method(func, module_name, action_name, args,
kwargs)
return wrapper
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))
except (
NotImplementedError, # For raw_output and run_as_user flags
FileNotFoundError, # When the .socket file is not present
ConnectionRefusedError, # When is daemon not running
ConnectionResetError # When daemon fails permission check
):
return _run_privileged_method_as_process(func, module_name,
action_name, args, kwargs)
return wrapper
def _read_from_server(client_socket: socket.socket) -> bytes:
"""Read everything from a socket and return the data."""
response = b''
while True:
chunk = client_socket.recv(4096)
if not chunk:
break
response += chunk
return json.loads(response)
def _request_to_server(request: dict) -> socket.socket:
"""Connect to the server and make a request."""
request_string = json.dumps(request)
client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
client_socket.connect(socket_path)
client_socket.sendall(request_string.encode('utf-8'))
# Close the write end of the socket signaling an EOF and no more data
# will be sent.
client_socket.shutdown(socket.SHUT_WR)
except Exception:
client_socket.close()
raise
return client_socket
def _run_privileged_method_on_server(func, module_name, action_name, args,
kwargs):
"""Execute a privileged method using a server."""
run_as_user = kwargs.pop('_run_as_user', None)
run_in_background = kwargs.pop('_run_in_background', False)
raw_output = kwargs.pop('_raw_output', False)
log_error = kwargs.pop('_log_error', True)
if raw_output or run_as_user:
raise NotImplementedError('Not yet implemented')
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
run_in_background, is_server=True)
request = {
'module': module_name,
'action': action_name,
'args': args,
'kwargs': kwargs
}
client_socket = _request_to_server(request)
args = (func, module_name, action_name, args, kwargs, log_error,
client_socket)
if not run_in_background:
return _wait_for_server_response(*args)
read_thread = threading.Thread(target=_wait_for_server_response, args=args)
read_thread.start()
def _wait_for_server_response(func, module_name, action_name, args, kwargs,
log_error, client_socket):
"""Wait for the server to respond and process the response."""
try:
return_value = _read_from_server(client_socket)
except json.JSONDecodeError:
logger.error('Error decoding action return value %s..%s(*%s, **%s)',
module_name, action_name, args, kwargs)
raise
finally:
client_socket.close()
if return_value['result'] == 'success':
return return_value['return']
module = importlib.import_module(return_value['exception']['module'])
exception_class = getattr(module, return_value['exception']['name'])
exception = exception_class(*return_value['exception']['args'])
exception.stdout = b''
exception.stderr = b''
def _get_html_message():
"""Return an HTML format error that can be shown in messages."""
from django.utils.html import format_html
formatted_args = _format_args(func, args, kwargs)
exception_args, stdout, stderr, traceback = _format_error(
exception, return_value)
return format_html('Error running action: {}..{}({}): {}({})\n{}{}{}',
module_name, action_name, formatted_args,
return_value['exception']['name'], exception_args,
stdout, stderr, traceback)
exception.get_html_message = _get_html_message
if log_error:
formatted_args = _format_args(func, args, kwargs)
exception_args, stdout, stderr, traceback = _format_error(
exception, return_value)
logger.error('Error running action %s..%s(%s): %s(%s)\n'
'%s%s%s', module_name, action_name, formatted_args,
return_value['exception']['name'], exception_args, stdout,
stderr, traceback)
raise exception
def _run_privileged_method_as_process(func, module_name, action_name, args,
@ -113,7 +239,7 @@ def _run_privileged_method_as_process(func, module_name, action_name, args,
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
_log_action(func, module_name, action_name, args, kwargs, run_as_user,
run_in_background)
run_in_background, is_server=False)
proc = subprocess.Popen(command, **proc_kwargs)
os.close(write_fd)
@ -297,9 +423,13 @@ def _get_privileged_action_module_name(func):
def _log_action(func, module_name, action_name, args, kwargs, run_as_user,
run_in_background):
run_in_background, is_server):
"""Log an action in a compact format."""
if is_server:
prompt = '»'
else:
prompt = f'({run_as_user})$' if run_as_user else '#'
suffix = '&' if run_in_background else ''
formatted_args = _format_args(func, args, kwargs)
logger.info('%s %s..%s(%s) %s', prompt, module_name, action_name,
@ -359,6 +489,54 @@ def privileged_main():
sys.exit(1)
def privileged_handle_json_request(request_string: str) -> str:
"""Parse arguments for the program spawned as a privileged action."""
def _parse_request() -> dict:
"""Return a JSON parsed and validated request."""
try:
request = json.loads(request_string)
except json.JSONDecodeError:
raise SyntaxError('Invalid JSON in request')
required_parameters = [('module', str), ('action', str),
('args', list), ('kwargs', dict)]
for parameter, expected_type in required_parameters:
if parameter not in request:
raise TypeError(f'Missing required parameter "{parameter}"')
if not isinstance(request[parameter], expected_type):
raise TypeError(f'Parameter "{parameter}" must be of type'
f'{expected_type.__name__}')
return request
try:
request = _parse_request()
logger.info('Received request for %s..%s(..)', request['module'],
request['action'])
arguments = {'args': request['args'], 'kwargs': request['kwargs']}
return_value = _privileged_call(request['module'], request['action'],
arguments)
except (PermissionError, SyntaxError, TypeError, Exception) as exception:
if isinstance(exception, (PermissionError, SyntaxError, TypeError)):
logger.error(exception.args[0])
else:
logger.exception(exception)
return_value = {
'result': 'exception',
'exception': {
'module': type(exception).__module__,
'name': type(exception).__name__,
'args': exception.args,
'traceback': traceback.format_tb(exception.__traceback__)
}
}
return json.dumps(return_value, cls=JSONEncoder)
def _privileged_call(module_name, action_name, arguments):
"""Import the module and run action as superuser"""
if '.' in module_name:
@ -368,7 +546,10 @@ def _privileged_call(module_name, action_name, arguments):
if module_name == 'plinth':
import_path = 'plinth'
else:
try:
import_path = module_loader.get_module_import_path(module_name)
except FileNotFoundError as exception:
raise SyntaxError('Specified module not found') from exception
try:
module = importlib.import_module(import_path + '.privileged')

View File

@ -596,6 +596,9 @@ class EnableState(LeaderComponent):
def apps_init():
"""Create apps by constructing them with components."""
if App.list():
return # Apps have already been initialized
from . import module_loader # noqa # Avoid circular import
for module_name, module in module_loader.loaded_modules.items():
_initialize_module(module_name, module)

View File

@ -6,6 +6,7 @@ pytest configuration for all tests.
import importlib
import os
import pathlib
from unittest.mock import patch
import pytest
@ -109,6 +110,17 @@ def fixture_needs_sudo():
pytest.skip('Needs sudo command installed.')
@pytest.fixture(name='no_privileged_server', scope='module')
def fixture_no_privileged__server():
"""Don't setup for and run privileged methods on server.
Tests on using privileged daemon are not yet implemented.
"""
with patch('plinth.actions._run_privileged_method_on_server') as mock:
mock.side_effect = NotImplementedError
yield
@pytest.fixture(scope='session')
def splinter_selenium_implicit_wait():
"""Disable implicit waiting."""

View File

@ -7,6 +7,7 @@ import importlib
import logging
import pathlib
import re
import types
import django
@ -15,7 +16,7 @@ from plinth.signals import pre_module_loading
logger = logging.getLogger(__name__)
loaded_modules = dict()
loaded_modules: dict[str, types.ModuleType] = dict()
_modules_to_load = None
@ -31,6 +32,9 @@ def load_modules():
Read names of enabled modules in modules/enabled directory and
import them from modules directory.
"""
if loaded_modules:
return # Modules have already been loaded
pre_module_loading.send_robust(sender="module_loader")
for module_import_path in get_modules_to_load():
module_name = module_import_path.split('.')[-1]

View File

@ -15,7 +15,8 @@ from plinth.modules.backups import privileged
from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
from plinth.tests import config as test_config
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg')
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg',
'no_privileged_server')
# try to access a non-existing url and a URL that exists but does not
# grant access

View File

@ -194,8 +194,11 @@ class Packages(app_module.FollowerComponent):
# Ensure package list is update-to-date before looking at dependencies.
refresh_package_lists()
# List of packages to purge from the system
packages = self.get_actual_packages()
# List of packages to purge from the system. Be resilient to a package
# not being found in apt's cache by not using get_actual_packages(). If
# a package expression is listed as (package1 | package2) then try to
# remove both packages.
packages = self.possible_packages
logger.info('App\'s list of packages to remove: %s', packages)
packages = self._filter_packages_to_keep(packages)
@ -304,7 +307,7 @@ class Packages(app_module.FollowerComponent):
# Remove packages used by other installed apps
for component in app.get_components_of_type(Packages):
keep_packages |= set(component.get_actual_packages())
keep_packages |= set(component.possible_packages)
# Get list of all the dependencies of packages to keep.
keep_packages_with_deps: set[str] = set()

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

View File

@ -20,6 +20,8 @@ from plinth.actions import privileged, secret_str
actions_name = 'actions'
pytestmark = pytest.mark.usefixtures('no_privileged_server')
@pytest.fixture(name='popen')
def fixture_popen():

View File

@ -31,7 +31,7 @@ from plinth.modules.firewall.components import get_port_forwarding_info
from plinth.package import Packages
from plinth.translation import get_language_from_request, set_language
from . import forms, frontpage, operation, package, setup
from . import forms, frontpage, operation, setup
REDIRECT_FIELD_NAME = 'next'
@ -537,11 +537,6 @@ class SetupView(TemplateView):
response.status_code = 303
return response
if 'refresh-packages' in request.POST:
# Refresh apt package lists
package.refresh_package_lists()
return self.render_to_response(self.get_context_data())
return super().dispatch(request, *args, **kwargs)
@staticmethod

View File

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