Compare commits

...

33 Commits

Author SHA1 Message Date
Sunil Mohan Adapa
4ed2a25a8b
locale: Fix a string formatting issue in Italian translation
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2025-09-29 16:43:30 -07:00
Veiko Aasa
dc837bd6b8
gitweb: Use Git credential helper when cloning URLs with credentials
This prevents logging usernames and passwords to the journal logs and to the
Git repo configuration. Also, avoids usernames and passwords appear in the
process list when cloning a repository.

Tests performed:
- Create a new repository by cloning an existing repository URL with basic
auth credentials. Check that:
  - Cloning succeeds.
  - Journal logs don't contain URLs with credential info.
  - The configuration of the cloned repository doesn't contain credential info.
- Try to clone a non-existing repository URL that contains credential
info. Cloning fails and there are no credential info in the journal logs.
- Cloning a public git repository without credential info succeeds.
- All the gitweb module tests pass.

Signed-off-by: Veiko Aasa <veiko17@disroot.org>
[sunil: Add/fix some more type hints]
[sunil: Add tests for URL parsing]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
2025-09-29 16:42:17 -07:00
Roman Akimov
e38ac648fe
Translated using Weblate (Russian)
Currently translated at 98.6% (1854 of 1879 strings)
2025-09-29 19:02:05 +00:00
Dietmar
1c0f25c134
Translated using Weblate (Italian)
Currently translated at 47.2% (887 of 1879 strings)
2025-09-29 19:02:03 +00:00
Dietmar
b559e1998a
Translated using Weblate (German)
Currently translated at 98.4% (1849 of 1879 strings)
2025-09-29 19:02:01 +00:00
Sunil Mohan Adapa
f2bceb48cf
backups: Don't show enable/disable button as app can't be disabled
Fixes: #2472.

Tests:

- On backups page, the enable/disable toggle button is not visible anymore.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 17:17:58 +03:00
Sunil Mohan Adapa
397a67329b
debian: Stop privileged service during upgrade or removal
- The privileged service will stop by itself if left idle for 5 minutes.
However, if someone is viewing a reloading page such as during manual software
update, the privileged service is never idle.

- When freedombox package is updated to a newer version, the old version of
privileged daemon could run for a long time but newer version of freedombox
service might be running by then. This would cause protocol mismatch
problems (unless backwards compatibility is provided which is unnecessarily
hard).

- Adding PartOf=.socket in .service file means that if .socket unit is stopped
or restarted, the .service unit will be stopped or restarted too. We still don't
want the dh_installsystemd script to be starting the .service unit, so this is
ideal.

Tests:

- During fresh install of freedombox package, freedombox-privilged.socket is
started but freedombox-privileged.service is not. It is started due to socket
activation (as seen in journal logs of privileged daemon).

- During removal of freedombox package, .service is stopped when .socket unit is
stopped.

- During reinstall of freedombox package, .service is restarted when .socket
unit is restarted.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:59:01 +03:00
Sunil Mohan Adapa
279738c305
actions: Raise an exception if privileged server response is empty
- These situation occur when server encounters an error when trying to formulate
a response. All exceptions during execution of actions are caught and reported
properly. However, server may encounter errors during processing of exception
raised in an action. Or may die abruptly. This special error will make
identifying such situations easier.

Tests:

- Add a 'return' after _read_request() in
privileged_daemon.py:RequestHandler:handle(). This will trigger this error.
Starting FreedomBox service will show these errors as 'ConnectionError: Server
returned empty response'. Similarly running 'freedombox-cmd --no-args plinth
is_package_manager_busy' will show the same error.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:59:00 +03:00
Sunil Mohan Adapa
f19ab68553
ci: Switch backports test to trixie-backports
Tests:

- None. Failing CI pipeline show be fixed.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:59 +03:00
Sunil Mohan Adapa
7f608cd570
*: Collect output for all privileged sub-processes
- Now that we have a mechanism for properly collecting, transmitting, and display
the stdout and stderr. There is no reason not to collect all of the stdin and
stderr.

- Also, the stdin/stderr=subprocess.PIPE is redundant and prevents the output
from getting collected for debugging. So, remove it.

Tests:

- Ran functional tests on backups, calibre, ejabberd, email, gitweb, ikiwiki,
infinoted, kiwix, mediawiki, mumble, nextcloud,, openvpn, samba, wireguard,
zoph. 2-3 issues were found but did not seem like new errors.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:57 +03:00
Sunil Mohan Adapa
b253166f6d
*: Use action_utils.run instead of subprocess.check_output
- This is to capture stdout and stderr and transmit that from privileged daemon
back to the service to be displayed in HTML.

Tests:

- Unit tests and code checks pass.

- Some of the modified actions work as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:56 +03:00
Sunil Mohan Adapa
bf9005ac48
*: Use action_utils.run instead of subprocess.call
- This is to capture stdout and stderr and transmit that from privileged daemon
back to the service to be displayed in HTML.

Tests:

- Unit tests and code checks pass.

- Some of the modified actions work as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:55 +03:00
Sunil Mohan Adapa
80e6d940a4
*: Use action_utils.run instead of subprocess.check_call
- This is to capture stdout and stderr and transmit that from privileged daemon
back to the service to be displayed in HTML.

Tests:

- Unit tests and code checks pass.

- Some of the modified actions work as expected.

- systemd daemon-reload is performed during infinoted setup.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:54 +03:00
Sunil Mohan Adapa
61ff15a04f
*: Use action_utils.run instead of subprocess.run
- This is to capture stdout and stderr and transmit that from privileged daemon
back to the service to be displayed in HTML.

Tests:

- Unit tests and code checks pass.

- Some of the modified actions work as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:53 +03:00
Sunil Mohan Adapa
355812c9f2
actions_utils: Fix issue with collecting stdout/stderr
- When an exception is raised in subprocess.run(), for that call the stdout and
stderr are not being collected. Any previous successful calls are being
collected.

- This also fixes issues with adding an existing backup repository back after
removal. Capturing stderr is essential for raising the proper exceptions and
working correctly.

Tests:

- Remove an existing backup repository and add it back again. It fails with the
patches and succeeds with the patches.

- Remove an existing encrypted backup repository and add it back again with the
wrong password. A proper error message is shown 'Incorrect encryption
passphrase'.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:51 +03:00
Sunil Mohan Adapa
f559870d3e
actions: Fix lifetime of thread local storage
- A local storage object must exist globally shared by all threads. Then
object.__dict__ is the thread specific storage. Absent this, when multiple
actions run in parallel, one will erase the thread local object of another.

Tests:

- When an error is raised in a privileged method, then the HTML error shown
contains stdout and stderr of the involved processes.

- Running functional tests on a lot of apps does not show this error anymore.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:50 +03:00
Sunil Mohan Adapa
2fbaea191f
setup: Log full exception traceback when setup fails
- When an error occurs during setup thread execution and the error is not due a
failed privileged action, we are left with very little information about what
went run. On the other than when a privileged action fails, we will be logging
the exception twice. But this is okay.

Tests:

- Increment the setup version of one of installed apps and raise an exception in
setup() method. Notice that exception traceback in the logged message.

- Increment the setup version of one of installed apps and raise an exception in
setup's privileged action. Notice that exception traceback in the logged
message twice.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:49 +03:00
Sunil Mohan Adapa
a43082308d
actions: Log full exception from privileged daemon on error
- This make it easy to find issues when looking at either main service logs or
privileged daemon logs.

Tests:

- Raise an exception in one of the privileged actions. Notice that the exception
is printed along with module name, action_name, stdout, stderr and traceback.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:48 +03:00
Sunil Mohan Adapa
5566f05cad
config: Set home page to FreedomBox for invalid values
- When attempting to set an invalid shortcut ID or invalid user's directory as
home page, set FreedomBox UI as home page.

- Simplify the tests somewhat and avoid failure first time and skipping the test
next time.

Tests:

- Run unit tests as 'root' and 'fbx' users.

- Set home page to apache default, FreedomBox, user home page and a shortcut.
The set value is retained. The change works when visiting / with browser. The
value is as expected in Apache configuration.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:47 +03:00
Sunil Mohan Adapa
647e72516c
backups: Fix robust handling of known errors
During functional tests, it was noticed that getattr() failed at the following
line. The original intent of the code is to ensure that there are no failures
when 'stdout'/'stderr' attribute are not present or when they return None.

    stdout = (getattr(err, 'stdout') or b'').decode()

Tests:

- Make the UI raise incorrect password error. Notice that the error is shown
properly.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:47 +03:00
Sunil Mohan Adapa
daca4d1d9c
actions: Log method arguments in privileged daemon
- This change means that when invalid module or action name is provided, the log
message is not printed. However, this is acceptable as those cases are rare in
production and are logged properly on the client side.

Tests:

- Run diagnostics for an app and notice that arguments are printed in privileged
daemon's journald logs.

- Remove a password from bepasty app and notice that the password argument is
not logged.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:46 +03:00
Sunil Mohan Adapa
1ad48ecad8
d/rules: Drop a workaround for dh_installsytemd needed for /usr/lib
Since debhelper 13.11.6, we don't need this hack as dh_installsystemd recognizes
the files in /usr/lib/systemd/ directory in addition to /lib/systemd/.

Tests:

- After build package with gbp. Notice that postinst script has code inserted by
dh_installsystemd for starting/restarting the service.

- Install the deb package starts service. Reinstalling the package restarts the
service.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:45 +03:00
Sunil Mohan Adapa
0fdf59b9f0
privileged_daemon: Implement handling termination signal
- And gracefully terminate the process after finishing the current requests
underway.

Tests:

- Trigger a long operation such as an app installation. While the operation is
underway, run 'systemctl stop freedombox-privilved.service'. Journal will show
that the SIGTERM is handled and shutdown is more or less immediately complete.
However, the whole process will wait until the ongoing request is complete and
then exit.

- During the wait period, no new requests are accepted as experienced with
'freedombox-cmd plinth is_package_manager_busy --no-args' command.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:44 +03:00
Sunil Mohan Adapa
636b4cabd8
actions: Work with older privileged daemon
- Older privileged daemon before 25.10 did not return the stdout/stderr
properties as part of an exception. During upgrade, there is a 5 minute time
window (longer if the privileged daemon is continuously used) when privileged
daemon is the old version and the service is the newer version. During this time
any exception in the privileged task will cause this problem.

- Our goal is not to always provide backward compatibility to old version of
privileged daemon as the web interface and privileged daemon are expected to be
upgraded at the same time. However, this one is easy and is complementary to a
separate fix that addresses the core problem.

Tests:

- Perform an operation that raises an Exception in a privileged method. The
error is properly shown as an HTML message but without stdout and stderr.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:43 +03:00
Sunil Mohan Adapa
288b58e0b5
storage: Fix disk usage checking with disconnected SSH mounts
- When disconnected sshfs mounts are present, then df command prints the disk
usage for the remaining disks but prints a warning to the stderr and return a
non-zero return code. Accommodate this case and parse the information for all
the available disks.

Tests:

- Create a remote backup location and mount it. When the SSH process is killed,
it leaves a mount point that is not properly connected. View the storage page to
see that disk usage for other partitions is shown properly.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:42 +03:00
Sunil Mohan Adapa
7c0fa00536
doc: Add manual page for freedombox-cmd
Tests:

- 'make -C doc' succeeds. 'man doc/freedombox-cmd.1' shows the manual page.

- Building and install .deb package installs the manual page to appropriate
location.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:42 +03:00
Sunil Mohan Adapa
c2d5d1d3c8
privileged_daemon: Fix showing errors for freedombox-cmd command
Tests:

- When arguments are not provided to freedombox-cmd it shows errors on the
console.

- When a command is successfully executed, the output is printed on the console.

- The output of the privileged daemon goes to the journald.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:41 +03:00
Sunil Mohan Adapa
c8f89e3ca5
action_utils: Handle capture_output argument in run wrapper
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:40 +03:00
Sunil Mohan Adapa
e227e9a919
Makefile: Move privileged daemon to /usr/lib/freedombox
Tests:

- Running make install installs to /usr/lib/freedombox. Non-privileged users
don't find it in the path. root user does.

- New service file contains path to /usr/lib/freedombox/. Actions works as
expected.

- Build and install the debian package. Privileged daemon runs as expected and
first setup steps complete as expected. First wizard works as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:40 +03:00
Sunil Mohan Adapa
904e5935cb
backups: Ignore a typing error with mypy
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:39 +03:00
Sunil Mohan Adapa
d512a8b645
diagnostics: In development mode, run diagnostics more rarely
Due the frequency and length of execution of diagnostics, the service does not
restart when files are modified. The operation also makes other testing tasks
wait until completed. It also makes functional tests slower. So, reduce the
frequency of execution. It can always be temporarily changed when debugging
diagnostics operations is necessary.

Tests:

- Change the development interval to 18 seconds and notice that new interval is
effective in development mode but not in production mode.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:39 +03:00
Sunil Mohan Adapa
996596ddc0
glib: Add schedule parameter for setting interval in develop mode
Tests:

- In development mode, diagnostics task runs after about 180 seconds (with
jitter).

- In production mode, diagnostics task does not run after 180 seconds.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:38 +03:00
Jiří Podhorecký
9c3776b03d
Translated using Weblate (Czech)
Currently translated at 100.0% (1879 of 1879 strings)
2025-09-28 07:02:01 +00:00
76 changed files with 866 additions and 688 deletions

2
.gitignore vendored
View File

@ -4,7 +4,7 @@
doc/manual/*/*.pdf
doc/manual/*/*.html
doc/manual/*/*.xml
doc/plinth.1
doc/*.1
doc/dev/_build
\#*
.#*

View File

@ -56,7 +56,7 @@ build:
build-backports:
extends: .build-package
variables:
RELEASE: bookworm-backports
RELEASE: trixie-backports
build i386:
extends: .build-package-i386

View File

@ -37,6 +37,7 @@ DIRECTORIES_TO_CREATE := \
STATIC_FILES_DIRECTORY := $(DESTDIR)/usr/share/plinth/static
BIN_DIR := $(DESTDIR)/usr/bin
LIB_DIR := $(DESTDIR)/usr/lib
FIND_ARGS := \
-not -iname "*.log" \
@ -102,7 +103,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
$(INSTALL) -D -t $(LIB_DIR)/freedombox bin/freedombox-privileged
$(INSTALL) -D -t $(BIN_DIR) bin/freedombox-cmd
# Static web server files

View File

@ -5,10 +5,12 @@ Description=FreedomBox Privileged Service
Documentation=https://wiki.debian.org/FreedomBox/
# Don't hit the start rate limiting.
StartLimitIntervalSec=0
# Stop/restart along with .socket unit (invoked from dpkg scripts).
PartOf=freedombox-privileged.socket
[Service]
Type=notify
ExecStart=/usr/bin/freedombox-privileged
ExecStart=/usr/lib/freedombox/freedombox-privileged
TimeoutSec=300s
User=root
Group=root

View File

@ -19,3 +19,8 @@ freedombox binary: web-application-works-only-with-apache
# Not documentation
freedombox: package-contains-documentation-outside-usr-share-doc [usr/share/plinth/static/jslicense.html]
freedombox: package-contains-documentation-outside-usr-share-doc [usr/lib/python3/dist-packages/plinth-*.dist-info/top_level.txt]
# This executable is meant to executed from systemd service file and is not
# meant for user. However, don't install to /usr/libexec and follow systemd
# convention instead.
freedombox: executable-in-usr-lib [usr/lib/freedombox/freedombox-privileged]

View File

@ -1 +1,2 @@
./doc/freedombox-cmd.1
./doc/plinth.1

10
debian/rules vendored
View File

@ -29,10 +29,6 @@ ifneq ($(FBX_VERSION),$(DEB_VERSION))
endif
override_dh_installsystemd:
# Do not enable or start any service other than FreedomBox service. Use
# of --tmpdir is a hack to workaround an issue with 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 freedombox-privileged.socket
# Do not enable or start any service other than FreedomBox service.
dh_installsystemd --package=freedombox plinth.service \
freedombox-privileged.socket

View File

@ -13,7 +13,7 @@ SCRIPTS_DIR=scripts
manual-pdfs=$(foreach lang,$(MANUAL_LANGUAGES),manual/$(lang)/freedombox-manual.pdf)
manual-xmls=$(patsubst %.pdf,%.xml,$(manual-pdfs))
OUTPUTS=$(manual-pdfs) plinth.1
OUTPUTS=$(manual-pdfs) plinth.1 freedombox-cmd.1
INSTALL_OPTS=-D --mode=644

160
doc/freedombox-cmd.xml Normal file
View File

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.4//EN" "http://www.docbook.org/xml/4.4/docbookx.dtd">
<!--
# SPDX-License-Identifier: AGPL-3.0-or-later
-->
<refentry>
<refmeta>
<refentrytitle><application>freedombox-cmd</application></refentrytitle>
<manvolnum>1</manvolnum>
<refmiscinfo class="manual">FreedomBox Command Line Utility</refmiscinfo>
<refmiscinfo class="version"> </refmiscinfo>
</refmeta>
<refnamediv>
<refname><application>freedombox-cmd</application></refname>
<refpurpose>
command line utility to perform FreedomBox operations
</refpurpose>
</refnamediv>
<refsynopsisdiv>
<cmdsynopsis>
<command>freedombox-cmd</command>
<arg><option>-h, </option><option>--help</option></arg>
<arg choice="req">module</arg>
<arg choice="req">action</arg>
<arg><option>--no-args</option></arg>
</cmdsynopsis>
</refsynopsisdiv>
<refsect1>
<title>Description</title>
<para>
FreedomBox is a community project to develop, design and promote
personal servers running free software for private, personal
communications. It is a networking appliance designed to allow
interfacing with the rest of the Internet under conditions of
protected privacy and data security. It hosts applications such
as blog, wiki, website, social network, email, web proxy and a
Tor relay on a device that can replace a wireless router so that
data stays with the users.
</para>
<para>
freedombox-cmd is a command line interface to some of the operations
performed by FreedomBox. It is typically not needed by the end users who
use FreedomBox's web interface. The command may be used in some cases
while debugging problems, especially where the web interface is not
accessible or when a piece of functionality that is not provided in the
web interface needs to be triggered.
</para>
<para>
The command is simply a client to the FreedomBox's privileged daemon and
relays user's request to it. It waits for the request to complete and
prints the output of the operation or an error message collected form the
daemon. The daemon only allows connections from an pre-allowed list of
user accounts. So, be sure to run the command as 'root' superuser.
</para>
</refsect1>
<refsect1>
<title>Options</title>
<variablelist>
<varlistentry>
<term><option>module</option></term>
<listitem>
<para>
Name of the module from which to execute an action.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>action</option></term>
<listitem>
<para>
Name of the action to execute. It should found in the provided
module.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--no-args</option></term>
<listitem>
<para>
Don't try to read the arguments to the command on the standard
input. Instead, assume that the operation does not have any
arguments and execute the method without arguments.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--help</option></term>
<listitem>
<para>
Show brief help about arguments allowed for this command.
</para>
</listitem>
</varlistentry>
</variablelist>
</refsect1>
<refsect1>
<title>Examples</title>
<example>
<title>Re-run FreedomBox network setup</title>
<synopsis>$ sudo freedombox-cmd networks setup --no-args</synopsis>
<para>
When FreedomBox starts for the first time, it will setup Network Manager
connections suitable for the hardware found. If you wish to re-create
these connections at a later time, you can re-run setup for the Networks
app using the web interface or run this command on a terminal.
</para>
</example>
<example>
<title>Delete a user account from LDAP database</title>
<synopsis>$ echo '{"args": ["USERNAME", "AUTH_USER", "AUTH_PASSWORD"], "kwargs": {}}' | sudo freedombox-cmd users remove_user</synopsis>
<para>
USERNAME is the name of the user account that must be removed. AUTH_USER
is name of the user account that is authorizing this operation.
AUTH_PASSWORD is the password for user account that is authorizing this
operation. This operation may be needed if FreedomBox's sqlite3 database
is wiped, removing the user accounts in the database but the
corresponding entries from LDAP database are not removed. A new user
with that name can't be created until the LDAP account is also removed.
</para>
</example>
<example>
<title>Set the logging mode to persistent</title>
<synopsis>$ echo '{"args": ["persistent"], "kwargs": {}}' | sudo freedombox-cmd config set_logging_mode</synopsis>
<para>
By default, FreedomBox sets up systemd-journald to 'volatile' logging.
This means that logs will not be stored on the disk and will be lost
after a reboot. If you are tackling a problem and wish to store the logs
persistently, you can change the setting in the web interface or run
this command.
</para>
</example>
</refsect1>
<refsect1>
<title>Bugs</title>
<para>
See <ulink
url="https://salsa.debian.org/freedombox-team/freedombox/issues">FreedomBox
issue tracker</ulink> for a full list of known issues and TODO items.
</para>
</refsect1>
<refsect1>
<title>Author</title>
<para>
<author>
<firstname>FreedomBox Developers</firstname>
<contrib>Original author</contrib>
</author>
</para>
</refsect1>
</refentry>

View File

@ -33,20 +33,18 @@ def is_systemd_running():
def systemd_get_default() -> str:
"""Return the default target that systemd will boot into."""
process = subprocess.run(['systemctl', 'get-default'],
stdout=subprocess.PIPE, check=True)
process = run(['systemctl', 'get-default'], check=True)
return process.stdout.decode().strip()
def systemd_set_default(target: str):
"""Set the default target that systemd will boot into."""
subprocess.run(['systemctl', 'set-default', target], check=True)
run(['systemctl', 'set-default', target], check=True)
def service_daemon_reload():
"""Reload systemd to ensure that newer unit files are read."""
subprocess.run(['systemctl', 'daemon-reload'], check=True,
stdout=subprocess.DEVNULL)
run(['systemctl', 'daemon-reload'], check=True)
def service_is_running(servicename):
@ -55,8 +53,7 @@ def service_is_running(servicename):
Does not need to run as root.
"""
try:
subprocess.run(['systemctl', 'status', servicename], check=True,
stdout=subprocess.DEVNULL)
run(['systemctl', 'status', servicename], check=True)
return True
except subprocess.CalledProcessError:
# If a service is not running we get a status code != 0 and
@ -102,9 +99,7 @@ def service_is_enabled(service_name, strict_check=False):
"""
try:
process = subprocess.run(['systemctl', 'is-enabled', service_name],
check=True, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
process = run(['systemctl', 'is-enabled', service_name], check=True)
if not strict_check:
return True
@ -115,13 +110,13 @@ def service_is_enabled(service_name, strict_check=False):
def service_enable(service_name: str, check: bool = False):
"""Enable and start a service in systemd."""
subprocess.run(['systemctl', 'enable', service_name], check=check)
run(['systemctl', 'enable', service_name], check=check)
service_start(service_name, check=check)
def service_disable(service_name: str, check: bool = False):
"""Disable and stop service in systemd."""
subprocess.run(['systemctl', 'disable', service_name], check=check)
run(['systemctl', 'disable', service_name], check=check)
try:
service_stop(service_name, check=check)
except subprocess.CalledProcessError:
@ -130,12 +125,12 @@ def service_disable(service_name: str, check: bool = False):
def service_mask(service_name: str, check: bool = False):
"""Mask a service"""
subprocess.run(['systemctl', 'mask', service_name], check=check)
run(['systemctl', 'mask', service_name], check=check)
def service_unmask(service_name: str, check: bool = False):
"""Unmask a service"""
subprocess.run(['systemctl', 'unmask', service_name], check=check)
run(['systemctl', 'unmask', service_name], check=check)
def service_start(service_name: str, check: bool = False):
@ -181,14 +176,14 @@ def service_get_logs(service_name: str) -> str:
command = [
'journalctl', '--no-pager', '--lines=200', '--unit', service_name
]
process = subprocess.run(command, check=False, stdout=subprocess.PIPE)
process = run(command, check=False)
return process.stdout.decode()
def service_show(service_name: str) -> dict[str, str]:
"""Return the status of the service in dictionary format."""
command = ['systemctl', 'show', service_name]
process = subprocess.run(command, check=False, stdout=subprocess.PIPE)
process = run(command, check=False)
status = {}
for line in process.stdout.decode().splitlines():
parts = line.partition('=')
@ -199,8 +194,7 @@ def service_show(service_name: str) -> dict[str, str]:
def service_action(service_name: str, action: str, check: bool = False):
"""Perform the given action on the service_name."""
subprocess.run(['systemctl', action, service_name],
stdout=subprocess.DEVNULL, check=check)
run(['systemctl', action, service_name], check=check)
def webserver_is_enabled(name, kind='config'):
@ -211,8 +205,7 @@ def webserver_is_enabled(name, kind='config'):
option_map = {'config': '-c', 'site': '-s', 'module': '-m'}
try:
# Don't print anything on the terminal
subprocess.check_output(['a2query', option_map[kind], name],
stderr=subprocess.STDOUT)
run(['a2query', option_map[kind], name], check=True)
return True
except subprocess.CalledProcessError:
return False
@ -234,7 +227,7 @@ def webserver_enable(name, kind='config', apply_changes=True):
'site': 'a2ensite',
'module': 'a2enmod'
}
subprocess.check_output([command_map[kind], name])
run([command_map[kind], name], check=True)
action_required = 'restart' if kind == 'module' else 'reload'
@ -263,7 +256,7 @@ def webserver_disable(name, kind='config', apply_changes=True):
'site': 'a2dissite',
'module': 'a2dismod'
}
subprocess.check_output([command_map[kind], name])
run([command_map[kind], name], check=True)
action_required = 'restart' if kind == 'module' else 'reload'
@ -391,7 +384,7 @@ def get_ip_addresses() -> list[dict[str, str | bool]]:
"""Return a list of IP addresses assigned to the system."""
addresses = []
output = subprocess.check_output(['ip', '-o', 'addr'])
output = run(['ip', '-o', 'addr'], check=True).stdout
for line in output.decode().splitlines():
parts = line.split()
address: dict[str, str | bool] = {
@ -417,7 +410,7 @@ def get_ip_addresses() -> list[dict[str, str | bool]]:
def get_hostname():
"""Return the current hostname."""
return subprocess.check_output(['hostname']).decode().strip()
return run(['hostname'], check=True).stdout.decode().strip()
def dpkg_reconfigure(package, config):
@ -440,7 +433,7 @@ Owners: {package}
env['DEBCONF_DB_OVERRIDE'] = 'File{' + override_file.name + \
' readonly:true}'
env['DEBIAN_FRONTEND'] = 'noninteractive'
subprocess.run(['dpkg-reconfigure', package], env=env, check=False)
run(['dpkg-reconfigure', package], env=env, check=False)
try:
os.remove(override_file.name)
@ -454,12 +447,12 @@ def debconf_set_selections(presets):
# Workaround Debian Bug #487300. In some situations, debconf complains
# it can't find the question being answered even though it is supposed
# to create a dummy question for it.
subprocess.run(['/usr/share/debconf/fix_db.pl'], check=True)
run(['/usr/share/debconf/fix_db.pl'], check=True)
except (FileNotFoundError, PermissionError):
pass
presets = '\n'.join(presets)
subprocess.check_output(['debconf-set-selections'], input=presets.encode())
run(['debconf-set-selections'], input=presets.encode(), check=True)
def is_disk_image():
@ -472,8 +465,7 @@ def is_disk_image():
return os.path.exists('/var/lib/freedombox/is-freedombox-disk-image')
def run_apt_command(arguments, stdout=subprocess.DEVNULL,
enable_triggers: bool = False):
def run_apt_command(arguments, enable_triggers: bool = False):
"""Run apt-get with provided arguments."""
command = ['apt-get', '--assume-yes', '--quiet=2'] + arguments
@ -481,8 +473,7 @@ def run_apt_command(arguments, stdout=subprocess.DEVNULL,
env['DEBIAN_FRONTEND'] = 'noninteractive'
if not enable_triggers:
env['FREEDOMBOX_INVOKED'] = 'true'
process = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=stdout,
env=env, check=False)
process = run(command, stdin=subprocess.DEVNULL, env=env, check=False)
return process.returncode
@ -503,25 +494,24 @@ def apt_hold(packages):
held_packages = []
try:
for package in packages:
current_hold = subprocess.check_output(
['apt-mark', 'showhold', package])
current_hold = run(['apt-mark', 'showhold', package],
check=True).stdout
if not current_hold:
process = subprocess.run(['apt-mark', 'hold', package],
check=False)
process = run(['apt-mark', 'hold', package], check=False)
if process.returncode == 0: # success
held_packages.append(package)
yield held_packages
finally:
for package in held_packages:
subprocess.check_call(['apt-mark', 'unhold', package])
run(['apt-mark', 'unhold', package], check=True)
@contextmanager
def apt_hold_freedombox():
"""Prevent freedombox package from being removed during apt operations."""
current_hold = subprocess.check_output(
['apt-mark', 'showhold', 'freedombox'])
current_hold = run(['apt-mark', 'showhold', 'freedombox'],
check=True).stdout
try:
if current_hold:
# Package is already held, possibly by administrator.
@ -530,7 +520,7 @@ def apt_hold_freedombox():
# Set the flag.
apt_hold_flag.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
apt_hold_flag.touch(mode=0o660)
yield subprocess.check_call(['apt-mark', 'hold', 'freedombox'])
yield run(['apt-mark', 'hold', 'freedombox'], check=True)
finally:
# Was the package held, either in this process or a previous one?
if not current_hold or apt_hold_flag.exists():
@ -539,9 +529,7 @@ def apt_hold_freedombox():
def apt_unhold_freedombox():
"""Remove any hold on freedombox package, and clear flag."""
subprocess.run(['apt-mark', 'unhold', 'freedombox'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False)
run(['apt-mark', 'unhold', 'freedombox'], check=False)
if apt_hold_flag.exists():
apt_hold_flag.unlink()
@ -552,7 +540,7 @@ def is_package_manager_busy():
is open which indicates that the package manager is busy"""
LOCK_FILE = '/var/lib/dpkg/lock'
try:
subprocess.check_output(['lsof', LOCK_FILE])
run(['lsof', LOCK_FILE], check=True)
return True
except subprocess.CalledProcessError:
return False
@ -568,15 +556,14 @@ def podman_create(container_name: str, image_name: str, volume_name: str,
service_stop(container_name)
# Data is kept
subprocess.run(['podman', 'volume', 'rm', '--force', volume_name],
check=False)
run(['podman', 'volume', 'rm', '--force', volume_name], check=False)
directory = pathlib.Path('/etc/containers/systemd')
directory.mkdir(parents=True, exist_ok=True)
# Fetch the image before creating the container. The systemd service for
# the container won't timeout due to slow internet connectivity.
subprocess.run(['podman', 'image', 'pull', image_name], check=True)
run(['podman', 'image', 'pull', image_name], check=True)
pathlib.Path(volume_path).mkdir(parents=True, exist_ok=True)
# Create storage volume
@ -735,10 +722,8 @@ def podman_disable(container_name: str):
def podman_uninstall(container_name: str, volume_name: str, image_name: str,
volume_path: str):
"""Remove a podman container's components and systemd unit."""
subprocess.run(['podman', 'volume', 'rm', '--force', volume_name],
check=True)
subprocess.run(['podman', 'image', 'rm', '--ignore', image_name],
check=True)
run(['podman', 'volume', 'rm', '--force', volume_name], check=True)
run(['podman', 'image', 'rm', '--ignore', image_name], check=True)
volume_file = pathlib.Path(
'/etc/containers/systemd/') / f'{volume_name}.volume'
volume_file.unlink(missing_ok=True)
@ -825,8 +810,10 @@ def run_as_user(command, username, **kwargs):
def run(command, **kwargs):
"""Run subprocess.run but capture stdout and stderr in thread storage."""
collect_stdout = ('stdout' not in kwargs)
collect_stderr = ('stderr' not in kwargs)
collect_stdout = ('stdout' not in kwargs
and 'capture_output' not in kwargs)
collect_stderr = ('stderr' not in kwargs
and 'capture_output' not in kwargs)
if collect_stdout:
kwargs['stdout'] = subprocess.PIPE
@ -834,11 +821,20 @@ def run(command, **kwargs):
if collect_stderr:
kwargs['stderr'] = subprocess.PIPE
process = subprocess.run(command, **kwargs)
if collect_stdout and actions.thread_storage:
actions.thread_storage.stdout += process.stdout
try:
process = subprocess.run(command, **kwargs)
if collect_stdout and hasattr(actions.thread_storage, 'stdout'):
actions.thread_storage.stdout += process.stdout
if collect_stderr and actions.thread_storage:
actions.thread_storage.stderr += process.stderr
if collect_stderr and hasattr(actions.thread_storage, 'stderr'):
actions.thread_storage.stderr += process.stderr
except subprocess.CalledProcessError as exception:
if exception.stdout and hasattr(actions.thread_storage, 'stdout'):
actions.thread_storage.stdout += exception.stdout
if exception.stderr and hasattr(actions.thread_storage, 'stderr'):
actions.thread_storage.stderr += exception.stderr
raise exception
return process

View File

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
socket_path = '/run/freedombox/privileged.socket'
thread_storage = None
thread_storage = threading.local()
# An alias for 'str' to mark some strings as sensitive. Sensitive strings are
@ -88,6 +88,9 @@ def _read_from_server(client_socket: socket.socket) -> bytes:
response += chunk
if not response:
raise ConnectionError('Server returned empty response')
return json.loads(response)
@ -170,8 +173,8 @@ def _wait_for_server_response(func, module_name, action_name, args, kwargs,
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 = return_value['exception']['stdout'].encode()
exception.stderr = return_value['exception']['stderr'].encode()
exception.stdout = return_value['exception'].get('stdout', b'').encode()
exception.stderr = return_value['exception'].get('stderr', b'').encode()
def _get_html_message():
"""Return an HTML format error that can be shown in messages."""
@ -364,7 +367,6 @@ class JSONEncoder(json.JSONEncoder):
def _setup_thread_storage():
"""Setup collection of stdout/stderr from any process in this thread."""
global thread_storage
thread_storage = threading.local()
thread_storage.stdout = b''
thread_storage.stderr = b''
@ -376,14 +378,13 @@ def _clear_thread_storage():
cleaned up after a thread terminates.
"""
global thread_storage
if thread_storage:
thread_storage.stdout = None
thread_storage.stderr = None
thread_storage = None
thread_storage.stdout = None
thread_storage.stderr = None
def get_return_value_from_exception(exception):
"""Return the value to return from server when an exception is raised."""
global thread_storage
return_value = {
'result': 'exception',
'exception': {
@ -391,14 +392,10 @@ def get_return_value_from_exception(exception):
'name': type(exception).__name__,
'args': exception.args,
'traceback': traceback.format_tb(exception.__traceback__),
'stdout': '',
'stderr': ''
'stdout': getattr(thread_storage, 'stdout', b'').decode(),
'stderr': getattr(thread_storage, 'stderr', b'').decode(),
}
}
if thread_storage:
return_value['exception']['stdout'] = thread_storage.stdout.decode()
return_value['exception']['stderr'] = thread_storage.stderr.decode()
return return_value
@ -431,8 +428,6 @@ def privileged_handle_json_request(
try:
request = _parse_request()
logger.info('Received request for %s..%s(..)', request['module'],
request['action'])
arguments = {'args': request['args'], 'kwargs': request['kwargs']}
_setup_thread_storage()
return_value = _privileged_call(request['module'], request['action'],
@ -495,6 +490,8 @@ def _privileged_call(module_name, action_name, arguments):
_privileged_assert_valid_arguments(func, arguments)
_log_action(func, module_name, action_name, arguments['args'],
arguments['kwargs'], run_in_background=False)
try:
return_values = func(*arguments['args'], **arguments['kwargs'])
if isinstance(return_values, io.BufferedReader):
@ -503,6 +500,11 @@ def _privileged_call(module_name, action_name, arguments):
return_value = {'result': 'success', 'return': return_values}
except Exception as exception:
return_value = get_return_value_from_exception(exception)
logger.exception(
'Error running action: %s..%s(..): %s\nstdout:\n%s\nstderr:\n%s\n',
module_name, action_name, exception,
return_value['exception']['stdout'],
return_value['exception']['stderr'])
return return_value

View File

@ -6,7 +6,6 @@ pytest configuration for all tests.
import importlib
import os
import pathlib
import subprocess
from unittest.mock import patch
import pytest
@ -188,7 +187,8 @@ def fixture_mock_run_as_user():
"""A fixture to override action_utils.run_as_user."""
def _bypass_runuser(*args, username, **kwargs):
return subprocess.run(*args, **kwargs)
from plinth import action_utils
return action_utils.run(*args, **kwargs)
with patch('plinth.action_utils.run_as_user') as mock:
mock.side_effect = _bypass_runuser

View File

@ -6,12 +6,14 @@ Uses utilities from 'mysql-client' package such as 'mysql' and 'mysqldump'.
import subprocess
from .. import action_utils
def run_query(database_name: str, query: str) -> subprocess.CompletedProcess:
"""Run a database query using 'root' user.
Does not ensure that the database server is running.
"""
return subprocess.run(
return action_utils.run(
['mysql', '--user=root', '--database', database_name],
input=query.encode('utf-8'), check=True)

View File

@ -6,7 +6,6 @@ Uses utilities from 'postgres' package such as 'psql' and 'pg_dump'.
import os
import pathlib
import subprocess
from plinth import action_utils
@ -14,7 +13,7 @@ from plinth import action_utils
def _run_as(command, **kwargs):
"""Run a command as 'postgres' user."""
command = ['sudo', '--user', 'postgres'] + command
return subprocess.run(command, check=True, **kwargs)
return action_utils.run(command, check=True, **kwargs)
def run_query(query):

View File

@ -51,8 +51,13 @@ def _run():
def schedule(interval, method, data=None, in_thread=True, repeat=True,
add_jitter=True):
"""Schedule a recurring call to a method with fixed interval."""
add_jitter=True, develop_interval=None):
"""Schedule a recurring call to a method with fixed interval.
develop_interval is number of seconds to schedule the task after when
running in development mode. A value of 180 seconds assumed if the value is
set to None or 0.
"""
def _runner():
"""Run the target method and log and exceptions."""
@ -74,8 +79,8 @@ def schedule(interval, method, data=None, in_thread=True, repeat=True,
# When running in development mode, reduce the interval for tasks so that
# they are triggered quickly and frequently to facilitate debugging.
if cfg.develop and interval > 180:
interval = 180
if cfg.develop:
interval = min(interval, develop_interval or 180)
if add_jitter:
# Add or subtract 5% random jitter to given interval to avoid many

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-23 00:05+0000\n"
"PO-Revision-Date: 2025-09-20 19:02+0000\n"
"PO-Revision-Date: 2025-09-28 07:02+0000\n"
"Last-Translator: Jiří Podhorecký <j.podhorecky@volny.cz>\n"
"Language-Team: Czech <https://hosted.weblate.org/projects/freedombox/"
"freedombox/cs/>\n"
@ -1640,6 +1640,8 @@ msgid ""
"This app also shows the <a href=\"{logs_url}\">logs</a> for {box_name} "
"services."
msgstr ""
"Tato aplikace také zobrazuje <a href=\"{logs_url}\">protokoly</a> pro služby "
"{box_name}."
#: plinth/modules/diagnostics/__init__.py:60
#: plinth/modules/diagnostics/__init__.py:254
@ -10437,13 +10439,7 @@ msgid "500"
msgstr "500"
#: plinth/templates/500.html:14
#, fuzzy, python-format
#| msgid ""
#| "This is an internal error and not something you caused or can fix. Please "
#| "report the error on the <a href=\"https://salsa.debian.org/freedombox-"
#| "team/freedombox/issues\">bug tracker</a> so we can fix it. Also, please "
#| "attach the <a href=\"%(status_log_url)s\">status log</a> to the bug "
#| "report."
#, python-format
msgid ""
"This is an internal error and not something you caused or can fix. Please "
"report the error on the <a href=\"https://salsa.debian.org/freedombox-team/"
@ -10451,31 +10447,26 @@ msgid ""
"the <a href=\"%(logs_url)s\">logs</a> to the bug report."
msgstr ""
"Jedná se o interní chybu, kterou jste nezpůsobili ani ji nemůžete opravit. "
"Nahlaste prosím chybu na <a href=\"https://salsa.debian.org/freedombox-team/"
"freedombox/issues\">sledovači chyb</a>, abychom ji mohli opravit. K hlášení "
"chyby prosím připojte také <a href=\"%(status_log_url)s\">status log</a>."
"Nahlaste prosím chybu na <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">sledovači "
"chyb</a>, abychom ji mohli opravit. K hlášení chyby prosím připojte také <a "
"href=\"%(logs_url)s\">protokoly</a>."
#: plinth/templates/app-header.html:26
msgid "Installation"
msgstr "Instalace"
#: plinth/templates/app-logs.html:12
#, fuzzy
#| msgid ""
#| "These are the last %(num_lines)s lines of the status log for this web "
#| "interface. If you want to report a bug, please use the <a href=\"https://"
#| "salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> and "
#| "attach this status log to the bug report."
msgid ""
"These are the last lines of the logs for services involved in this app. If "
"you want to report a bug, please use the <a href=\"https://salsa.debian.org/"
"freedombox-team/freedombox/issues\">bug tracker</a> and attach this log to "
"the bug report."
msgstr ""
"Toto je posledních %(num_lines)s řádků stavového protokolu pro toto webové "
"rozhraní. Pokud chcete nahlásit chybu, použijte prosím <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">prohlížeč chyb</a> a "
"připojte tento stavový protokol k hlášení chyby."
"Toto jsou poslední řádky protokolů služeb souvisejících s touto aplikací. "
"Pokud chcete nahlásit chybu, použijte prosím <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</"
"a> a připojte tento protokol k hlášení o chybě."
#: plinth/templates/app-logs.html:26
msgid ""
@ -10736,10 +10727,8 @@ msgid "Clear all tags"
msgstr "Vymazat všechny štítky"
#: plinth/templates/toolbar.html:39 plinth/templates/toolbar.html:40
#, fuzzy
#| msgid "Logs"
msgid "View Logs"
msgstr "Protokoly"
msgstr "Zobrazit Protokoly"
#: plinth/templates/toolbar.html:46 plinth/templates/toolbar.html:47
msgid "Backup"

View File

@ -10,7 +10,7 @@ msgstr ""
"Project-Id-Version: FreedomBox UI\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-23 00:05+0000\n"
"PO-Revision-Date: 2025-09-11 18:01+0000\n"
"PO-Revision-Date: 2025-09-29 19:01+0000\n"
"Last-Translator: Dietmar <sagen@permondes.de>\n"
"Language-Team: German <https://hosted.weblate.org/projects/freedombox/"
"freedombox/de/>\n"
@ -1680,6 +1680,8 @@ msgid ""
"This app also shows the <a href=\"{logs_url}\">logs</a> for {box_name} "
"services."
msgstr ""
"Diese App zeigt auch die <a href=\"{logs_url}\">Protokolle</a> für {box_name}"
" Dienste an."
#: plinth/modules/diagnostics/__init__.py:60
#: plinth/modules/diagnostics/__init__.py:254
@ -10687,46 +10689,34 @@ msgid "500"
msgstr "500"
#: plinth/templates/500.html:14
#, fuzzy, python-format
#| msgid ""
#| "This is an internal error and not something you caused or can fix. Please "
#| "report the error on the <a href=\"https://salsa.debian.org/freedombox-"
#| "team/freedombox/issues\">bug tracker</a> so we can fix it. Also, please "
#| "attach the <a href=\"%(status_log_url)s\">status log</a> to the bug "
#| "report."
#, python-format
msgid ""
"This is an internal error and not something you caused or can fix. Please "
"report the error on the <a href=\"https://salsa.debian.org/freedombox-team/"
"freedombox/issues\">bug tracker</a> so we can fix it. Also, please attach "
"the <a href=\"%(logs_url)s\">logs</a> to the bug report."
msgstr ""
"Dies ist ein interner Fehler und nicht etwas, das Sie verursacht haben oder "
"beheben können. Bitte melden Sie den Fehler im <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">online Fehlermelder</"
"a>, so dass wir ihn beheben können. Fügen Sie auch das <a "
"href=\"%(status_log_url)s\">Statusprotokoll</a> dem Fehlerbericht bei."
"Dies ist ein interner Fehler, den Sie weder verursacht haben noch beheben "
"können. Bitte melden Sie den Fehler im <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">Bug-Tracker</"
"a>, damit wir ihn beheben können. Fügen Sie dem Fehlerbericht bitte auch die "
"<a href=\"%(logs_url)s\">Protokolle</a> bei."
#: plinth/templates/app-header.html:26
msgid "Installation"
msgstr "Installation"
#: plinth/templates/app-logs.html:12
#, fuzzy
#| msgid ""
#| "These are the last %(num_lines)s lines of the status log for this web "
#| "interface. If you want to report a bug, please use the <a href=\"https://"
#| "salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> and "
#| "attach this status log to the bug report."
msgid ""
"These are the last lines of the logs for services involved in this app. If "
"you want to report a bug, please use the <a href=\"https://salsa.debian.org/"
"freedombox-team/freedombox/issues\">bug tracker</a> and attach this log to "
"the bug report."
msgstr ""
"Dies sind die letzten %(num_lines)s Zeilen des Statusprotokolls der "
"Weboberfläche. Bitte melden Sie den Fehler im <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">online Fehlerverfolger</"
"a> und fügen diese Ausgabe hinzu."
"Dies sind die letzten Zeilen der Protokolle für Dienste, die an dieser App "
"beteiligt sind. Wenn Sie einen Fehler melden möchten, verwenden Sie bitte "
"den <a href=\"https://salsa.debian.org/freedombox-team/freedombox/issues\""
">Bug-Tracker</a> und fügen Sie dieses Protokoll dem Fehlerbericht bei."
#: plinth/templates/app-logs.html:26
msgid ""

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-23 00:05+0000\n"
"PO-Revision-Date: 2025-09-11 18:02+0000\n"
"PO-Revision-Date: 2025-09-29 19:02+0000\n"
"Last-Translator: Dietmar <sagen@permondes.de>\n"
"Language-Team: Italian <https://hosted.weblate.org/projects/freedombox/"
"freedombox/it/>\n"
@ -1617,6 +1617,8 @@ msgid ""
"This app also shows the <a href=\"{logs_url}\">logs</a> for {box_name} "
"services."
msgstr ""
"Questa app mostra anche i <a href=\"{logs_url}\">log</a> per i servizi "
"{box_name}."
#: plinth/modules/diagnostics/__init__.py:60
#: plinth/modules/diagnostics/__init__.py:254
@ -9739,13 +9741,7 @@ msgid "500"
msgstr "500"
#: plinth/templates/500.html:14
#, fuzzy, python-format
#| msgid ""
#| "This is an internal error and not something you caused or can fix. Please "
#| "report the error on the <a href=\"https://salsa.debian.org/freedombox-"
#| "team/freedombox/issues\">bug tracker</a> so we can fix it. Also, please "
#| "attach the <a href=\"%(status_log_url)s\">status log</a> to the bug "
#| "report."
#, python-format
msgid ""
"This is an internal error and not something you caused or can fix. Please "
"report the error on the <a href=\"https://salsa.debian.org/freedombox-team/"
@ -9753,32 +9749,26 @@ msgid ""
"the <a href=\"%(logs_url)s\">logs</a> to the bug report."
msgstr ""
"Si tratta di un errore interno e non di qualcosa che hai causato o che puoi "
"correggere. Si prega di segnalare l'errore sul <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> in modo "
"da poterlo correggere. Inoltre, si prega di allegare il <a "
"href=\"%(status_log_url)s\">status log</a> alla segnalazione del bug."
"correggere. Si prega di segnalare l'errore sul <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</"
"a> in modo da poterlo correggere. Inoltre, si prega di allegare i <a href="
"\"%(logs_url)s\">status log</a> alla segnalazione del bug."
#: plinth/templates/app-header.html:26
msgid "Installation"
msgstr ""
#: plinth/templates/app-logs.html:12
#, fuzzy
#| msgid ""
#| "These are the last %(num_lines)s lines of the status log for this web "
#| "interface. If you want to report a bug, please use the <a href=\"https://"
#| "salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> and "
#| "attach this status log to the bug report."
msgid ""
"These are the last lines of the logs for services involved in this app. If "
"you want to report a bug, please use the <a href=\"https://salsa.debian.org/"
"freedombox-team/freedombox/issues\">bug tracker</a> and attach this log to "
"the bug report."
msgstr ""
"Queste sono le ultime %(num_lines)s del status log di questa interfaccia "
"web. Se vuoi riportare un bug, prego usa il <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> e "
"allega questo status log report del bug."
"Queste sono le ultime righe dei status log di questa app. Se vuoi riportare "
"un bug, prego usa il <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</"
"a> e allega questo status log report del bug."
#: plinth/templates/app-logs.html:26
msgid ""

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-23 00:05+0000\n"
"PO-Revision-Date: 2025-09-18 10:01+0000\n"
"PO-Revision-Date: 2025-09-29 19:02+0000\n"
"Last-Translator: Roman Akimov <zoompyc@gmail.com>\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/freedombox/"
"freedombox/ru/>\n"
@ -213,7 +213,7 @@ msgstr "Местный"
#: plinth/modules/avahi/manifest.py:14
msgid "mDNS"
msgstr "mDNS"
msgstr ""
#: plinth/modules/backups/__init__.py:24
msgid "Backups allows creating and managing backup archives."
@ -503,7 +503,7 @@ msgstr "Конфигурация"
#: plinth/modules/backups/manifest.py:21
msgid "Borg"
msgstr "Borg"
msgstr ""
#: plinth/modules/backups/privileged.py:34
msgid ""
@ -935,7 +935,7 @@ msgstr "Список и чтение всех файлов"
#: plinth/modules/bepasty/__init__.py:57 plinth/modules/bepasty/manifest.py:6
msgid "bepasty"
msgstr "bepasty"
msgstr ""
#: plinth/modules/bepasty/forms.py:17
msgid "Public Access (default permissions)"
@ -977,7 +977,7 @@ msgstr "Обмен файлами"
#: plinth/modules/bepasty/manifest.py:23
msgid "Pastebin"
msgstr "Pastebin"
msgstr ""
#: plinth/modules/bepasty/templates/bepasty.html:12
msgid "Manage Passwords"
@ -1658,6 +1658,8 @@ msgid ""
"This app also shows the <a href=\"{logs_url}\">logs</a> for {box_name} "
"services."
msgstr ""
"Это приложение также показывает <a href=\"{logs_url}\">журналы</a> для "
"{box_name} ."
#: plinth/modules/diagnostics/__init__.py:60
#: plinth/modules/diagnostics/__init__.py:254
@ -2455,7 +2457,7 @@ msgstr "Thunderbird"
#: plinth/modules/email/manifest.py:37
msgid "Thunderbird Mobile"
msgstr "Thunderbird Mobile"
msgstr ""
#: plinth/modules/email/manifest.py:52
msgid "FairEmail"
@ -2531,8 +2533,6 @@ msgid "Host/Target/Value"
msgstr "Хост/Цель/Значение"
#: plinth/modules/email/templates/email-dns.html:50
#, fuzzy
#| msgid "Server hostname or IP address"
msgid "Reverse DNS Records for IP Addresses"
msgstr "Обратные записи DNS для IP-адресов"
@ -2566,8 +2566,6 @@ msgstr ""
"разделе. Это можно настроить в приложении конфиденциальности."
#: plinth/modules/email/templates/email-dns.html:76
#, fuzzy
#| msgid "Hostname"
msgid "Host"
msgstr "Хост"
@ -2584,8 +2582,7 @@ msgstr ""
"записей DNS домена."
#: plinth/modules/email/templates/email.html:35
#, fuzzy, python-format
#| msgid "Resolve domain name: {domain}"
#, python-format
msgid "View domain: %(domain)s"
msgstr "Просмотр домена: %(domain)s"
@ -2705,9 +2702,8 @@ msgstr "Веб-сайт"
#: plinth/modules/featherwiki/manifest.py:18
#: plinth/modules/tiddlywiki/manifest.py:25
#, fuzzy
msgid "Quine"
msgstr "Quine"
msgstr ""
#: plinth/modules/featherwiki/manifest.py:18
#: plinth/modules/nextcloud/manifest.py:56
@ -3247,8 +3243,6 @@ msgstr ""
"силу."
#: plinth/modules/gnome/__init__.py:48
#, fuzzy
#| msgid "GNOME Files"
msgid "GNOME"
msgstr "GNOME"
@ -3257,8 +3251,6 @@ msgid "Desktop"
msgstr "Десктоп"
#: plinth/modules/gnome/manifest.py:10
#, fuzzy
#| msgid "Tor Browser"
msgid "Browser"
msgstr "Браузер"
@ -3267,8 +3259,6 @@ msgid "Office suite"
msgstr "Офисный пакет"
#: plinth/modules/gnome/manifest.py:12
#, fuzzy
#| msgid "Software Update"
msgid "Software store"
msgstr "Магазин программного обеспечения"
@ -3658,12 +3648,6 @@ msgid "{box_name} Manual"
msgstr "Руководство {box_name}"
#: plinth/modules/homeassistant/__init__.py:31
#, fuzzy
#| msgid ""
#| "Home Assistant is a home automation hub with emphasis on local control "
#| "and privacy. It integrates with thousands of devices including smart "
#| "bulbs, alarms, presense sensors, door bells, thermostats, irrigation "
#| "timers, energy monitors, etc."
msgid ""
"Home Assistant is a home automation hub with emphasis on local control and "
"privacy. It integrates with thousands of devices including smart bulbs, "
@ -3727,9 +3711,8 @@ msgid "Home Automation"
msgstr "Домашняя автоматизация"
#: plinth/modules/homeassistant/manifest.py:63
#, fuzzy
msgid "IoT"
msgstr "IoT"
msgstr ""
#: plinth/modules/homeassistant/manifest.py:64
#: plinth/modules/networks/manifest.py:8
@ -3742,11 +3725,11 @@ msgstr "Wi-Fi"
#: plinth/modules/homeassistant/manifest.py:65
msgid "ZigBee"
msgstr "ZigBee"
msgstr ""
#: plinth/modules/homeassistant/manifest.py:66
msgid "Z-Wave"
msgstr "Z-Wave"
msgstr ""
#: plinth/modules/homeassistant/manifest.py:67
msgid "Thread"
@ -3955,7 +3938,7 @@ msgstr "Видеокомната Janus"
#: plinth/modules/janus/manifest.py:16
msgid "WebRTC"
msgstr "WebRTC"
msgstr ""
#: plinth/modules/janus/manifest.py:16
msgid "Web conference"
@ -4038,7 +4021,7 @@ msgstr "Управление сервером контента Kiwix"
#: plinth/modules/kiwix/__init__.py:56 plinth/modules/kiwix/manifest.py:8
msgid "Kiwix"
msgstr "Kiwix"
msgstr ""
#: plinth/modules/kiwix/forms.py:23
msgid "Content packages have to be in .zim format"
@ -4302,6 +4285,10 @@ msgid ""
"only be installed if frequent feature updates is enabled in the <a "
"href=\"{upgrades_url}\">Software Update</a> app."
msgstr ""
"<strong>Примечание:</strong> Это приложение часто получает обновления. Оно "
"может быть установлено только в том случае, если в системе включено частое "
"обновление функций. <a href=\"{upgrades_url}\">Обновление программного "
"обеспечения</a> ."
#: plinth/modules/matrixsynapse/__init__.py:59
msgid "Matrix Synapse"
@ -4775,8 +4762,6 @@ msgid "UPnP"
msgstr "UPnP"
#: plinth/modules/minidlna/manifest.py:116
#, fuzzy
#| msgid "MiniDLNA"
msgid "DLNA"
msgstr "DLNA"
@ -4814,9 +4799,8 @@ msgstr ""
#: plinth/modules/miniflux/__init__.py:42
#: plinth/modules/miniflux/manifest.py:10
#, fuzzy
msgid "Miniflux"
msgstr "Miniflux"
msgstr ""
#: plinth/modules/miniflux/forms.py:12
msgid "Enter a username for the user."
@ -4839,28 +4823,24 @@ msgid "Passwords do not match."
msgstr "Пароли не совпадают."
#: plinth/modules/miniflux/manifest.py:18
#, fuzzy
msgid "Fluent Reader Lite"
msgstr "Fluent Reader Lite"
msgstr ""
#: plinth/modules/miniflux/manifest.py:33
msgid "Fluent Reader"
msgstr "Беглое чтение"
#: plinth/modules/miniflux/manifest.py:46
#, fuzzy
msgid "FluxNews"
msgstr "FluxNews"
msgstr ""
#: plinth/modules/miniflux/manifest.py:61
#, fuzzy
msgid "MiniFlutt"
msgstr "MiniFlutt"
msgstr ""
#: plinth/modules/miniflux/manifest.py:71
#, fuzzy
msgid "NetNewsWire"
msgstr "NetNewsWire"
msgstr ""
#: plinth/modules/miniflux/manifest.py:86
msgid "Newsflash"
@ -4884,14 +4864,11 @@ msgstr "Агрегация новостей"
#: plinth/modules/miniflux/manifest.py:138
#: plinth/modules/rssbridge/manifest.py:16 plinth/modules/ttrss/manifest.py:55
#, fuzzy
#| msgid "SSH"
msgid "RSS"
msgstr "RSS"
#: plinth/modules/miniflux/manifest.py:138
#: plinth/modules/rssbridge/manifest.py:16 plinth/modules/ttrss/manifest.py:55
#, fuzzy
msgid "ATOM"
msgstr "ATOM"
@ -5201,7 +5178,7 @@ msgstr "Ссылка"
#: plinth/modules/names/templates/names.html:104
#: plinth/modules/networks/templates/connection_show.html:268
msgid "DNS-over-TLS"
msgstr "DNS-over-TLS"
msgstr ""
#: plinth/modules/names/templates/names.html:108
msgid "DNSSEC"
@ -6249,7 +6226,7 @@ msgstr "локальная ссылка"
#: plinth/modules/networks/views.py:32
msgid "dhcp"
msgstr "dhcp"
msgstr ""
#: plinth/modules/networks/views.py:33
msgid "ignore"
@ -6509,7 +6486,7 @@ msgstr ""
#: plinth/modules/nextcloud/manifest.py:11
#: plinth/modules/nextcloud/manifest.py:18
msgid "Nextcloud"
msgstr "Nextcloud"
msgstr ""
#: plinth/modules/nextcloud/forms.py:19
msgid "Not set"
@ -7233,11 +7210,11 @@ msgstr "Контакты"
#: plinth/modules/radicale/manifest.py:91 plinth/modules/sogo/manifest.py:75
msgid "CalDAV"
msgstr "CalDAV"
msgstr ""
#: plinth/modules/radicale/manifest.py:91 plinth/modules/sogo/manifest.py:76
msgid "CardDAV"
msgstr "CardDAV"
msgstr ""
#: plinth/modules/radicale/views.py:32
msgid "Access rights configuration updated"
@ -7833,7 +7810,7 @@ msgstr "Точка входа"
#: plinth/modules/shadowsocks/manifest.py:22
#: plinth/modules/shadowsocksserver/manifest.py:21
msgid "Shadowsocks"
msgstr "Shadowsocks"
msgstr ""
#: plinth/modules/shadowsocksserver/__init__.py:26
#, python-brace-format
@ -8102,7 +8079,7 @@ msgstr "Заведомо исправное состояние"
#: plinth/modules/snapshot/manifest.py:14
msgid "Btrfs"
msgstr "Btrfs"
msgstr ""
#: plinth/modules/snapshot/templates/snapshot_delete_selected.html:12
msgid "Delete the following snapshots permanently?"
@ -8730,7 +8707,7 @@ msgstr ""
#: plinth/modules/tiddlywiki/__init__.py:64
#: plinth/modules/tiddlywiki/manifest.py:9
msgid "TiddlyWiki"
msgstr "TiddlyWiki"
msgstr ""
#: plinth/modules/tiddlywiki/forms.py:39
msgid "A TiddlyWiki file with .html file extension"
@ -9103,7 +9080,7 @@ msgstr "TTRSS-читатель"
#: plinth/modules/ttrss/manifest.py:25
msgid "Geekttrss"
msgstr "Geekttrss"
msgstr ""
#: plinth/modules/upgrades/__init__.py:34
msgid "Check for and apply the latest software and security updates."
@ -9367,7 +9344,7 @@ msgstr "Автоматическое обновление отключено."
#: plinth/modules/upgrades/templates/upgrades-dist-upgrade.html:54
msgid "Distribution upgrades are disabled."
msgstr "Обновления дистрибутива отключены"
msgstr "Обновления дистрибутива отключены."
#: plinth/modules/upgrades/templates/upgrades-dist-upgrade.html:58
msgid ""
@ -10543,13 +10520,7 @@ msgid "500"
msgstr "500"
#: plinth/templates/500.html:14
#, fuzzy, python-format
#| msgid ""
#| "This is an internal error and not something you caused or can fix. Please "
#| "report the error on the <a href=\"https://salsa.debian.org/freedombox-"
#| "team/freedombox/issues\">bug tracker</a> so we can fix it. Also, please "
#| "attach the <a href=\"%(status_log_url)s\">status log</a> to the bug "
#| "report."
#, python-format
msgid ""
"This is an internal error and not something you caused or can fix. Please "
"report the error on the <a href=\"https://salsa.debian.org/freedombox-team/"
@ -10557,32 +10528,26 @@ msgid ""
"the <a href=\"%(logs_url)s\">logs</a> to the bug report."
msgstr ""
"Это внутренняя ошибка, а не то, что вы вызвали или можете исправить. "
"Сообщите об ошибке в <a href=\"https://salsa.debian.org/freedombox-team/"
"freedombox/issues\">трекере ошибок</a>, чтобы мы могли ее исправить. Также "
"приложите <a href=\"%(status_log_url)s\">журнал состояния</a> к отчету об "
"ошибке."
"Пожалуйста, сообщите об ошибке на <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">трекере "
"ошибок</a> чтобы мы могли это исправить. Кроме того, пожалуйста, приложите "
"<a href=\"%(logs_url)s\">файлы журналов</a> к сообщению об ошибке."
#: plinth/templates/app-header.html:26
msgid "Installation"
msgstr "Установка"
#: plinth/templates/app-logs.html:12
#, fuzzy
#| msgid ""
#| "These are the last %(num_lines)s lines of the status log for this web "
#| "interface. If you want to report a bug, please use the <a href=\"https://"
#| "salsa.debian.org/freedombox-team/freedombox/issues\">bug tracker</a> and "
#| "attach this status log to the bug report."
msgid ""
"These are the last lines of the logs for services involved in this app. If "
"you want to report a bug, please use the <a href=\"https://salsa.debian.org/"
"freedombox-team/freedombox/issues\">bug tracker</a> and attach this log to "
"the bug report."
msgstr ""
"Это последние %(num_lines)s строк журнала состояния для этого веб-"
"интерфейса. Если вы хотите сообщить об ошибке, используйте <a href=\"https://"
"salsa.debian.org/freedombox-team/freedombox/issues\">трекер ошибок</a> и "
"прикрепите этот журнал состояния к отчету об ошибке."
"Это последние строки журналов для служб, задействованных в этом приложении. "
"Если вы хотите сообщить об ошибке, пожалуйста, воспользуйтесь <a href="
"\"https://salsa.debian.org/freedombox-team/freedombox/issues\">отслеживаним "
"ошибок</a> и приложите этот журнал к отчету об ошибке."
#: plinth/templates/app-logs.html:26
msgid ""
@ -10844,10 +10809,8 @@ msgid "Clear all tags"
msgstr "Очистить все теги"
#: plinth/templates/toolbar.html:39 plinth/templates/toolbar.html:40
#, fuzzy
#| msgid "Logs"
msgid "View Logs"
msgstr "Журналы"
msgstr "Просмотр журналов"
#: plinth/templates/toolbar.html:46 plinth/templates/toolbar.html:47
msgid "Backup"

View File

@ -90,13 +90,16 @@ def _capture_warnings():
warnings.filterwarnings('default', '', ImportWarning)
def action_init():
def action_init(console: bool = False):
"""Initialize logging for action scripts."""
_capture_warnings()
configuration = get_configuration()
# Don't log to console
configuration['root']['handlers'] = ['journal']
if console:
configuration['root']['handlers'] = ['console']
else:
configuration['root']['handlers'] = ['journal']
logging.config.dictConfig(configuration)

View File

@ -5,7 +5,6 @@ import glob
import os
import pathlib
import re
import subprocess
from plinth import action_utils
from plinth.actions import privileged
@ -62,14 +61,14 @@ def setup(old_version: int):
# version of Apache FreedomBox app and setting up for the first time don't
# regenerate.
if action_utils.is_disk_image() and old_version == 0:
subprocess.run([
action_utils.run([
'make-ssl-cert', 'generate-default-snakeoil', '--force-overwrite'
], check=True)
# In case the certificate has been removed after ssl-cert is installed
# on a fresh Debian machine.
elif not os.path.exists('/etc/ssl/certs/ssl-cert-snakeoil.pem'):
subprocess.run(['make-ssl-cert', 'generate-default-snakeoil'],
check=True)
action_utils.run(['make-ssl-cert', 'generate-default-snakeoil'],
check=True)
with action_utils.WebserverChange() as webserver:
# Disable mod_php as we have switched to mod_fcgi + php-fpm. Disable

View File

@ -35,6 +35,8 @@ class BackupsApp(app_module.App):
_version = 3
can_be_disabled = False
def __init__(self) -> None:
"""Create components for the app."""
super().__init__()

View File

@ -118,8 +118,8 @@ def reraise_known_errors(privileged_func):
def _reraise_known_errors(err):
"""Look whether the caught error is known and reraise it accordingly"""
stdout = (getattr(err, 'stdout') or b'').decode()
stderr = (getattr(err, 'stderr') or b'').decode()
stdout = (getattr(err, 'stdout', b'') or b'').decode()
stderr = (getattr(err, 'stderr', b'') or b'').decode()
caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS:
for error in known_error['errors']:
@ -194,7 +194,7 @@ def _is_mounted(mountpoint):
cmd = ['mountpoint', '-q', mountpoint]
# mountpoint exits with status non-zero if it didn't find a mountpoint
try:
subprocess.run(cmd, check=True)
action_utils.run(cmd, check=True)
return True
except subprocess.CalledProcessError:
return False
@ -244,8 +244,7 @@ def init(path: str, encryption: str,
@privileged
def info(path: str, encryption_passphrase: secret_str | None = None) -> dict:
"""Show repository information."""
process = _run(['borg', 'info', '--json', path], encryption_passphrase,
stdout=subprocess.PIPE)
process = _run(['borg', 'info', '--json', path], encryption_passphrase)
return json.loads(process.stdout.decode())
@ -255,7 +254,7 @@ def list_repo(path: str,
encryption_passphrase: secret_str | None = None) -> dict:
"""List repository contents."""
process = _run(['borg', 'list', '--json', '--format="{comment}"', path],
encryption_passphrase, stdout=subprocess.PIPE)
encryption_passphrase)
return json.loads(process.stdout.decode())
@ -281,7 +280,7 @@ def remove_uploaded_archive(file_path: str):
def _get_borg_version():
"""Return the version of borgbackup."""
process = _run(['borg', '--version'], stdout=subprocess.PIPE)
process = _run(['borg', '--version'])
return process.stdout.decode().split()[1] # Example: "borg 1.1.9"
@ -322,10 +321,7 @@ def _extract(archive_path, destination, encryption_passphrase, locations=None):
try:
os.chdir(os.path.expanduser(destination))
# TODO: with python 3.7 use subprocess.run with the 'capture_output'
# argument
process = _run(borg_call, encryption_passphrase, check=False,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process = _run(borg_call, encryption_passphrase, check=False)
if process.returncode != 0:
error = process.stderr.decode()
# Don't fail on the borg error when no files were matched
@ -350,8 +346,7 @@ def export_tar(path: str, encryption_passphrase: secret_str | None = None):
def _read_archive_file(archive, filepath, encryption_passphrase):
"""Read the content of a file inside an archive."""
borg_call = ['borg', 'extract', archive, filepath, '--stdout']
return _run(borg_call, encryption_passphrase,
stdout=subprocess.PIPE).stdout.decode()
return _run(borg_call, encryption_passphrase).stdout.decode()
@reraise_known_errors
@ -365,8 +360,7 @@ def get_archive_apps(
'borg', 'list', path, manifest_folder, '--format', '{path}{NEWLINE}'
]
try:
borg_process = _run(borg_call, encryption_passphrase,
stdout=subprocess.PIPE)
borg_process = _run(borg_call, encryption_passphrase)
manifest_path = borg_process.stdout.decode().strip()
except subprocess.CalledProcessError:
raise RuntimeError('Borg exited unsuccessfully')

View File

@ -237,7 +237,7 @@ class RootBorgRepository(BaseBorgRepository):
UUID = 'root'
PATH = '/var/lib/freedombox/borgbackup'
storage_type = 'root'
storage_type = 'root' # type: ignore
name = format_lazy(_('{box_name} storage'), box_name=_(cfg.box_name))
borg_path = PATH
sort_order = 10
@ -252,7 +252,7 @@ class RootBorgRepository(BaseBorgRepository):
class BorgRepository(BaseBorgRepository):
"""General Borg repository implementation."""
known_credentials = ['encryption_passphrase']
storage_type = 'disk'
storage_type = 'disk' # type: ignore
sort_order = 20
flags = {'removable': True}
@ -273,7 +273,7 @@ class SshBorgRepository(BaseBorgRepository):
known_credentials = [
'ssh_keyfile', 'ssh_password', 'encryption_passphrase'
]
storage_type = 'ssh'
storage_type = 'ssh' # type: ignore
sort_order = 30
flags = {'removable': True, 'mountable': True}

View File

@ -10,7 +10,6 @@ import pwd
import secrets
import shutil
import string
import subprocess
import augeas
@ -71,13 +70,13 @@ def setup(domain_name: str):
try:
grp.getgrnam('bepasty')
except KeyError:
subprocess.run(['addgroup', '--system', 'bepasty'], check=True)
action_utils.run(['addgroup', '--system', 'bepasty'], check=True)
# Create bepasty user if needed.
try:
pwd.getpwnam('bepasty')
except KeyError:
subprocess.run([
action_utils.run([
'adduser', '--system', '--ingroup', 'bepasty', '--home',
'/var/lib/bepasty', '--gecos', 'bepasty file sharing', 'bepasty'
], check=True)
@ -174,5 +173,5 @@ def uninstall():
"""Remove bepasty user, group and data."""
shutil.rmtree(DATA_DIR, ignore_errors=True)
CONF_FILE.unlink(missing_ok=True)
subprocess.run(['deluser', 'bepasty'], check=False)
subprocess.run(['delgroup', 'bepasty'], check=False)
action_utils.run(['deluser', 'bepasty'], check=False)
action_utils.run(['delgroup', 'bepasty'], check=False)

View File

@ -3,7 +3,6 @@
import pathlib
import shutil
import subprocess
from plinth import action_utils
from plinth.actions import privileged
@ -28,9 +27,9 @@ def create_library(name: str):
calibre.validate_library_name(name)
library = LIBRARIES_PATH / name
library.mkdir(mode=0o755) # Raise exception if already exists
subprocess.call(
action_utils.run(
['calibredb', '--with-library', library, 'list_categories'],
stdout=subprocess.DEVNULL)
check=False)
# Force systemd StateDirectory= logic to assign proper ownership to the
# DynamicUser=

View File

@ -29,9 +29,8 @@ def fixture_patch():
path = pathlib.Path(args[0][2]) / 'metadata.db'
path.touch()
with patch('subprocess.call') as subprocess_call, \
patch('subprocess.run'), patch('shutil.chown'):
subprocess_call.side_effect = side_effect
with patch('subprocess.run') as subprocess_run, patch('shutil.chown'):
subprocess_run.side_effect = side_effect
yield

View File

@ -109,23 +109,23 @@ def home_page_url2scid(url: str | None):
def _home_page_scid2url(shortcut_id: str) -> str | None:
"""Return the url for the given home page shortcut ID."""
url: str | None = '/plinth/'
if shortcut_id == 'plinth':
url = '/plinth/'
pass
elif shortcut_id == 'apache-default':
url = None
elif shortcut_id.startswith('uws-'):
user = shortcut_id[4:]
if user in get_users_with_website():
url = uws_url_of_user(user)
else:
url = None
else:
shortcuts = frontpage.Shortcut.list()
aux = [
shortcut.url for shortcut in shortcuts
if shortcut_id == shortcut.component_id
]
url = aux[0] if 1 == len(aux) else None
if 1 == len(aux):
url = aux[0]
return url

View File

@ -4,11 +4,13 @@ Tests for config module.
"""
import os
from unittest.mock import MagicMock, patch
import pathlib
from unittest.mock import Mock, patch
import pytest
from plinth import __main__ as plinth_main
from plinth import utils
from plinth.modules.apache import uws_directory_of_user, uws_url_of_user
from plinth.modules.config import (_home_page_scid2url, change_home_page,
get_home_page, home_page_url2scid)
@ -61,23 +63,14 @@ def test_homepage_mapping_skip_ci():
# AC: Return None if it doesn't:
os.rmdir(uws_directory)
assert _home_page_scid2url(uws_scid) is None
assert _home_page_scid2url(uws_scid) == '/plinth/'
class Dict2Obj:
"""Mock object made out of any dict."""
def __init__(self, a_dict):
self.__dict__ = a_dict
@patch('plinth.frontpage.Shortcut.list',
MagicMock(return_value=[
Dict2Obj({
'url': 'url/for/' + id,
'component_id': id
}) for id in ('a', 'b')
]))
@patch(
'plinth.frontpage.Shortcut.list',
Mock(return_value=[
Mock(url='url/for/' + id, component_id=id) for id in ('a', 'b')
]))
@pytest.mark.usefixtures('needs_root')
def test_homepage_field():
"""Test homepage changes.
@ -104,23 +97,14 @@ def test_homepage_field():
Currently they share the same test case.
- Search for another valid user apart from fbx.
"""
user = 'fbx'
user = 'test_' + utils.random_string(size=12)
uws_directory = uws_directory_of_user(user)
uws_url = uws_url_of_user(user)
uws_scid = home_page_url2scid(uws_url)
default_home_page = 'plinth'
original_home_page = get_home_page() or default_home_page
# Check test's preconditions:
if original_home_page not in (default_home_page, None):
reason = "Unexpected home page {}.".format(original_home_page)
pytest.skip(reason)
if os.path.exists(uws_directory):
# Don't blindly remove a pre-existing directory. Just skip the test.
reason = "UWS directory {} exists already.".format(uws_directory)
pytest.skip(reason)
change_home_page(default_home_page) # Set to known value explicitly
# AC: invalid changes fall back to default:
for scid in ('uws-unexisting', uws_scid, 'missing_app'):
@ -128,12 +112,7 @@ def test_homepage_field():
assert get_home_page() == default_home_page
# AC: valid changes actually happen:
try:
os.mkdir(uws_directory)
except Exception:
reason = "Needs access to ~/ directory. " \
+ "CI sandboxed workspace doesn't provide it."
pytest.skip(reason)
pathlib.Path(uws_directory).mkdir(parents=True)
for scid in ('b', 'a', uws_scid, 'apache-default', 'plinth'):
change_home_page(scid)
assert get_home_page() == scid

View File

@ -1,8 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Set time zone with timedatectl."""
import subprocess
from plinth import action_utils
from plinth.actions import privileged
@ -10,4 +9,4 @@ from plinth.actions import privileged
def set_timezone(timezone: str):
"""Set time zone with timedatectl."""
command = ['timedatectl', 'set-timezone', timezone]
subprocess.run(command, stdout=subprocess.DEVNULL, check=True)
action_utils.run(command, check=True)

View File

@ -86,8 +86,9 @@ class DiagnosticsApp(app_module.App):
# Check periodically for low RAM space
glib.schedule(3600, _warn_about_low_ram_space)
# Run diagnostics once a day
glib.schedule(24 * 3600, _daily_diagnostics_run, in_thread=False)
# Run diagnostics once a day or every 30 minutes in development mode.
glib.schedule(24 * 3600, _daily_diagnostics_run, in_thread=False,
develop_interval=1800)
def setup(self, old_version):
"""Install and configure the app."""

View File

@ -89,7 +89,7 @@ def setup(domain_name: str):
_upgrade_config(domain_name)
try:
subprocess.check_output(['ejabberdctl', 'restart'])
action_utils.run(['ejabberdctl', 'restart'], check=True)
except subprocess.CalledProcessError as err:
logger.warn('Failed to restart ejabberd with new configuration: %s',
err)
@ -145,11 +145,11 @@ def pre_change_hostname(old_hostname: str, new_hostname: str):
logger.info('ejabberdctl not found')
return
subprocess.call(['ejabberdctl', 'backup', EJABBERD_BACKUP])
subprocess.check_output([
action_utils.run(['ejabberdctl', 'backup', EJABBERD_BACKUP], check=False)
action_utils.run([
'ejabberdctl', 'mnesia-change-nodename', 'ejabberd@' + old_hostname,
'ejabberd@' + new_hostname, EJABBERD_BACKUP, EJABBERD_BACKUP_NEW
])
], check=True)
os.remove(EJABBERD_BACKUP)
@ -160,20 +160,20 @@ def change_hostname():
return
action_utils.service_stop('ejabberd')
subprocess.call(['pkill', '-u', 'ejabberd'])
action_utils.run(['pkill', '-u', 'ejabberd'], check=False)
# Make sure there aren't files in the Mnesia spool dir
os.makedirs('/var/lib/ejabberd/oldfiles', exist_ok=True)
subprocess.call('mv /var/lib/ejabberd/*.* /var/lib/ejabberd/oldfiles/',
shell=True)
action_utils.run('mv /var/lib/ejabberd/*.* /var/lib/ejabberd/oldfiles/',
shell=True, check=False)
action_utils.service_start('ejabberd')
# restore backup database
if os.path.exists(EJABBERD_BACKUP_NEW):
try:
subprocess.check_output(
['ejabberdctl', 'restore', EJABBERD_BACKUP_NEW])
action_utils.run(['ejabberdctl', 'restore', EJABBERD_BACKUP_NEW],
check=True)
os.remove(EJABBERD_BACKUP_NEW)
except subprocess.CalledProcessError as err:
logger.error('Failed to restore ejabberd backup database: %s', err)
@ -278,7 +278,7 @@ def mam(command: str) -> bool | None:
yaml.dump(conf, file_handle)
if action_utils.service_is_running('ejabberd'):
subprocess.call(['ejabberdctl', 'reload_config'])
action_utils.run(['ejabberdctl', 'reload_config'], check=False)
return None
@ -359,14 +359,14 @@ def configure_turn(turn_server_config: dict[str, Any], managed: bool):
Path(EJABBERD_MANAGED_COTURN).unlink(missing_ok=True)
if action_utils.service_is_running('ejabberd'):
subprocess.call(['ejabberdctl', 'reload_config'])
action_utils.run(['ejabberdctl', 'reload_config'], check=False)
def _get_version():
"""Get the current ejabberd version."""
try:
output = subprocess.check_output(['ejabberdctl',
'status']).decode('utf-8')
output = action_utils.run(['ejabberdctl', 'status'],
check=True).stdout.decode('utf-8')
except subprocess.CalledProcessError:
return None

View File

@ -11,6 +11,8 @@ import re
import subprocess
from dataclasses import dataclass
from plinth import action_utils
@dataclass
class Service: # NOQA, pylint: disable=too-many-instance-attributes
@ -109,7 +111,7 @@ def _run(args):
Raise a RuntimeError on non-zero exit codes.
"""
try:
result = subprocess.run(args, check=True, capture_output=True)
result = action_utils.run(args, check=True)
return result.stdout.decode()
except subprocess.SubprocessError as subprocess_error:
raise RuntimeError('Subprocess failed') from subprocess_error

View File

@ -7,8 +7,8 @@ See: https://rspamd.com/doc/modules/dkim_signing.html
import pathlib
import re
import shutil
import subprocess
from plinth import action_utils
from plinth.actions import privileged
from plinth.privileged import service as service_privileged
@ -30,9 +30,9 @@ def get_dkim_public_key(domain: str) -> str:
"""Privileged action to get the public key from DKIM key."""
_validate_domain_name(domain)
key_file = _keys_dir / f'{domain}.dkim.key'
output = subprocess.check_output(
output = action_utils.run(
['openssl', 'rsa', '-in',
str(key_file), '-pubout'], stderr=subprocess.DEVNULL)
str(key_file), '-pubout'], check=True).stdout
return ''.join(output.decode().splitlines()[1:-1])
@ -54,7 +54,7 @@ def setup_dkim(domain: str):
# Ed25519 is widely *not* accepted as of 2022-01. See:
# https://serverfault.com/questions/1023674
subprocess.run([
action_utils.run([
'rspamadm', 'dkim_keygen', '-t', 'rsa', '-b', '2048', '-s', 'dkim',
'-d', domain, '-k', (str(key_file))
], check=True)

View File

@ -6,8 +6,7 @@ See:
https://doc.dovecot.org/configuration_manual/authentication/user_databases_userdb/
"""
import subprocess
from plinth import action_utils
from plinth.actions import privileged
@ -19,4 +18,4 @@ def setup_home():
Dovecot creates new directories with the same permissions as the parent
directory. Ensure that 'others' can't access /var/mail/.
"""
subprocess.run(['chmod', 'o-rwx', '/var/mail'], check=True)
action_utils.run(['chmod', 'o-rwx', '/var/mail'], check=True)

View File

@ -10,8 +10,8 @@ For testing DKIM signatures: https://www.mail-tester.com/
import pathlib
import re
import subprocess
from plinth import action_utils
from plinth.actions import privileged
from plinth.modules.email import postfix
@ -31,10 +31,11 @@ def setup_spam():
def _compile_sieve():
"""Compile all .sieve script to binary format for performance."""
sieve_dirs = ['/etc/dovecot/freedombox-sieve-after/',
'/etc/dovecot/freedombox-sieve']
sieve_dirs = [
'/etc/dovecot/freedombox-sieve-after/', '/etc/dovecot/freedombox-sieve'
]
for sieve_dir in sieve_dirs:
subprocess.run(['sievec', sieve_dir], check=True)
action_utils.run(['sievec', sieve_dir], check=True)
def _setup_rspamd():

View File

@ -36,10 +36,10 @@ def _flush_iptables_rules():
iptables_rules += rule_template.format(table=table)
ip6tables_rules += rule_template.format(table=table)
subprocess.run(['iptables-restore'], input=iptables_rules.encode(),
check=True)
subprocess.run(['ip6tables-restore'], input=iptables_rules.encode(),
check=True)
action_utils.run(['iptables-restore'], input=iptables_rules.encode(),
check=True)
action_utils.run(['ip6tables-restore'], input=iptables_rules.encode(),
check=True)
def set_firewall_backend(backend):
@ -67,8 +67,7 @@ def set_firewall_backend(backend):
def _run_firewall_cmd(args):
"""Run firewall-cmd command, discard output and check return value."""
subprocess.run(['firewall-cmd'] + args, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=True)
action_utils.run(['firewall-cmd'] + args, check=True)
def _setup_local_service_protection():
@ -160,9 +159,8 @@ def _setup_inter_zone_forwarding():
def setup():
"""Perform basic firewalld setup."""
action_utils.service_enable('firewalld')
subprocess.run(['firewall-cmd', '--set-default-zone=external'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=True)
action_utils.run(['firewall-cmd', '--set-default-zone=external'],
check=True)
set_firewall_backend('nftables')
_setup_local_service_protection()
@ -176,7 +174,8 @@ def get_config() -> FirewallConfig:
config: FirewallConfig = {}
# Get the default zone.
output = subprocess.check_output(['firewall-cmd', '--get-default-zone'])
output = action_utils.run(['firewall-cmd', '--get-default-zone'],
check=True).stdout
config['default_zone'] = output.decode().strip()
# Load Augeas lens.
@ -191,8 +190,9 @@ def get_config() -> FirewallConfig:
config['backend'] = aug.get('FirewallBackend')
# Get the list of direct passthroughs.
output = subprocess.check_output(
['firewall-cmd', '--direct', '--get-all-passthroughs'])
output = action_utils.run(
['firewall-cmd', '--direct', '--get-all-passthroughs'],
check=True).stdout
config['passthroughs'] = output.decode().strip().split('\n')
return config

View File

@ -34,7 +34,7 @@ class GitwebApp(app_module.App):
app_id = 'gitweb'
_version = 3
_version = 4
def __init__(self) -> None:
"""Create components for the app."""

View File

@ -4,14 +4,16 @@
import configparser
import logging
import os
import pathlib
import re
import shutil
import subprocess
import time
from typing import Any
from urllib import parse
from plinth import action_utils
from plinth.actions import privileged
from plinth.actions import privileged, secret_str
from plinth.modules.gitweb.forms import RepositoryValidator, get_name_from_url
from plinth.modules.gitweb.manifest import GIT_REPO_PATH, REPO_DIR_OWNER
@ -27,7 +29,7 @@ def validate_repo_name(name: str) -> str:
return name
def validate_repo_url(url: str) -> str:
def validate_repo_url(url: secret_str) -> secret_str:
"""Validate a repository URL."""
RepositoryValidator(input_should_be='url')(url)
return url
@ -35,33 +37,58 @@ def validate_repo_url(url: str) -> str:
@privileged
def setup():
"""Disable default Apache2 Gitweb configuration."""
"""Configure Gitweb module."""
# Disable default Apache2 Gitweb configuration.
action_utils.webserver_disable('gitweb')
if not _get_global_default_branch():
_set_global_default_branch('main')
# Configure Git client.
if not _get_git_global_config('init.defaultBranch'):
_set_git_global_config('init.defaultBranch', 'main')
if not _get_git_global_config('credential.helper'):
_set_git_global_config('credential.helper', 'cache')
def _get_global_default_branch():
"""Get globally configured default branch name."""
def _get_git_global_config(key: str) -> str | None:
"""Return a value from Git global configuration."""
try:
default_branch = subprocess.check_output(
['git', 'config', '--global', '--get',
'init.defaultBranch']).decode().strip()
value = action_utils.run(['git', 'config', '--global', '--get', key],
check=True).stdout.decode().strip()
except subprocess.CalledProcessError as exception:
if exception.returncode == 1: # Default branch not configured
if exception.returncode == 1: # Configuration option doesn't exist
return None
raise
return default_branch
return value
def _set_global_default_branch(name):
"""Configure default branch name globally."""
subprocess.check_call(
['git', 'config', '--global', 'init.defaultBranch', name])
def _set_git_global_config(key: str, value: str) -> None:
"""Set a Git global configuration value."""
action_utils.run(['git', 'config', '--global', key, value], check=True)
def _clone_with_progress_report(url, repo_dir):
def _setup_git_credentials(url: secret_str) -> str:
"""Set up git credential helper and return URL without credentials."""
url_parts = parse.urlsplit(url)
safe_netloc = url_parts.netloc.split('@')[-1]
safe_url = url_parts._replace(netloc=safe_netloc).geturl()
username = url_parts.username or ''
password = url_parts.password or ''
if username or password:
# Feed credentials to Git credential helper
input = (f'protocol={url_parts.scheme}\n'
f'host={safe_netloc}\n'
f'username={username}\n'
f'password={password}\n\n')
env = dict(os.environ, GIT_TERMINAL_PROMPT='0')
action_utils.run(['git', 'credential', 'approve'],
input=input.encode(), stdout=subprocess.DEVNULL,
check=True, env=env)
return safe_url
def _clone_with_progress_report(url: secret_str, repo_dir: pathlib.Path):
"""Clone a repository and write progress info to the file."""
starttime = time.time()
status_file = repo_dir / 'clone_progress'
@ -70,9 +97,12 @@ def _clone_with_progress_report(url, repo_dir):
env = dict(os.environ, GIT_TERMINAL_PROMPT='0', LC_ALL='C',
GIT_HTTP_LOW_SPEED_LIMIT='100', GIT_HTTP_LOW_SPEED_TIME='60')
safe_url = _setup_git_credentials(url)
logger.info(f'Cloning Git repository {safe_url} ...')
proc = subprocess.Popen(
['git', 'clone', '--bare', '--progress', url,
['git', 'clone', '--bare', '--progress', safe_url,
str(repo_temp_dir)], stderr=subprocess.PIPE, text=True, env=env)
assert proc.stderr is not None
# write clone progress to the file
errors = []
@ -108,7 +138,7 @@ def _clone_with_progress_report(url, repo_dir):
raise RuntimeError('Git repository cloning failed.', errors)
def _prepare_clone_repo(url: str, is_private: bool):
def _prepare_clone_repo(url: secret_str, is_private: bool):
"""Prepare cloning a repository."""
repo_name = get_name_from_url(url)
if not repo_name.endswith('.git'):
@ -150,7 +180,8 @@ def _clone_status_line_to_percent(line):
return None
def _clone_repo(url: str, description: str, owner: str, keep_ownership: bool):
def _clone_repo(url: secret_str, description: str, owner: str,
keep_ownership: bool):
"""Clone a repository."""
repo = get_name_from_url(url)
if not repo.endswith('.git'):
@ -166,9 +197,9 @@ def _clone_repo(url: str, description: str, owner: str, keep_ownership: bool):
shutil.rmtree(repo_temp_path)
if not keep_ownership:
subprocess.check_call(
action_utils.run(
['chown', '-R', f'{REPO_DIR_OWNER}:{REPO_DIR_OWNER}', repo],
cwd=GIT_REPO_PATH)
cwd=GIT_REPO_PATH, check=True)
_set_repo_description(repo, description)
_set_repo_owner(repo, owner)
@ -178,12 +209,12 @@ def _create_repo(repo: str, description: str, owner: str, is_private: bool,
keep_ownership: bool):
"""Create an empty repository."""
try:
subprocess.check_call(['git', 'init', '-q', '--bare', repo],
cwd=GIT_REPO_PATH)
action_utils.run(['git', 'init', '-q', '--bare', repo],
cwd=GIT_REPO_PATH, check=True)
if not keep_ownership:
subprocess.check_call(
action_utils.run(
['chown', '-R', f'{REPO_DIR_OWNER}:{REPO_DIR_OWNER}', repo],
cwd=GIT_REPO_PATH)
cwd=GIT_REPO_PATH, check=True)
_set_repo_description(repo, description)
_set_repo_owner(repo, owner)
if is_private:
@ -202,8 +233,7 @@ def _get_default_branch(repo):
return action_utils.run_as_user(
['git', '-C',
str(repo_path), 'symbolic-ref', '--short', 'HEAD'],
username=REPO_DIR_OWNER, check=True,
stdout=subprocess.PIPE).stdout.decode().strip()
username=REPO_DIR_OWNER, check=True).stdout.decode().strip()
def _get_repo_description(repo):
@ -271,8 +301,7 @@ def _get_branches(repo):
"""Return list of the branches in the repository."""
process = action_utils.run_as_user(
['git', '-C', repo, 'branch', '--format=%(refname:short)'],
cwd=GIT_REPO_PATH, username=REPO_DIR_OWNER, check=True,
stdout=subprocess.PIPE)
cwd=GIT_REPO_PATH, username=REPO_DIR_OWNER, check=True)
return process.stdout.decode().strip().split()
@ -345,7 +374,7 @@ def repo_info(name: str) -> dict[str, str]:
@privileged
def create_repo(url: str | None = None, name: str | None = None,
def create_repo(url: secret_str | None = None, name: str | None = None,
description: str = '', owner: str = '',
keep_ownership: bool = False, is_private: bool = False,
skip_prepare: bool = False, prepare_only: bool = False):
@ -367,13 +396,14 @@ def create_repo(url: str | None = None, name: str | None = None,
@privileged
def repo_exists(url: str) -> bool:
def repo_exists(url: secret_str) -> bool:
"""Return whether remote repository exists."""
url = validate_repo_url(url)
safe_url = _setup_git_credentials(url)
env = dict(os.environ, GIT_TERMINAL_PROMPT='0')
try:
subprocess.check_call(['git', 'ls-remote', url, 'HEAD'], timeout=10,
env=env)
action_utils.run(['git', 'ls-remote', safe_url, 'HEAD'], timeout=10,
env=env, check=True)
return True
except subprocess.CalledProcessError:
return False

View File

@ -2,6 +2,8 @@
"""Test module for gitweb module operations."""
import pathlib
import subprocess
from unittest.mock import call, patch
import pytest
from django.forms import ValidationError
@ -121,3 +123,26 @@ def test_action_create_repo_with_invalid_urls(url):
with pytest.raises(ValidationError):
privileged.create_repo(url=url, description='', owner='',
keep_ownership=True)
@patch('plinth.action_utils.run')
def test_setup_git_creentials(run):
"""Test that setting up git credentials works."""
url = 'https://user:pass@host.example/path?key=value'
safe_url = privileged._setup_git_credentials(url)
assert safe_url == 'https://host.example/path?key=value'
input_ = b'protocol=https\nhost=host.example\nusername=user\n' \
b'password=pass\n\n'
env = run.mock_calls[0].kwargs.pop('env')
assert env['GIT_TERMINAL_PROMPT'] == '0'
assert run.mock_calls == [
call(['git', 'credential', 'approve'], input=input_,
stdout=subprocess.DEVNULL, check=True)
]
run.reset_mock()
url = 'https://host2.example/path?key=value'
safe_url = privileged._setup_git_credentials(url)
assert safe_url == 'https://host2.example/path?key=value'
run.assert_not_called()

View File

@ -5,8 +5,8 @@ import os
import pathlib
import re
import shutil
import subprocess
from plinth import action_utils
from plinth.actions import privileged, secret_str
SETUP_WIKI = '/etc/ikiwiki/plinth-wiki.setup'
@ -61,10 +61,9 @@ def create_wiki(wiki_name: str, admin_name: str, admin_password: secret_str):
"""Create a wiki."""
pw_bytes = admin_password.encode()
input_ = pw_bytes + b'\n' + pw_bytes
subprocess.run(['ikiwiki', '-setup', SETUP_WIKI, wiki_name, admin_name],
stdout=subprocess.PIPE, input=input_,
stderr=subprocess.PIPE,
env=dict(os.environ, PERL_UNICODE='AS'), check=True)
action_utils.run(['ikiwiki', '-setup', SETUP_WIKI, wiki_name, admin_name],
input=input_, env=dict(os.environ,
PERL_UNICODE='AS'), check=True)
@privileged
@ -72,17 +71,15 @@ def create_blog(blog_name: str, admin_name: str, admin_password: secret_str):
"""Create a blog."""
pw_bytes = admin_password.encode()
input_ = pw_bytes + b'\n' + pw_bytes
subprocess.run(['ikiwiki', '-setup', SETUP_BLOG, blog_name, admin_name],
stdout=subprocess.PIPE, input=input_,
stderr=subprocess.PIPE, env=dict(os.environ,
PERL_UNICODE='AS'))
action_utils.run(['ikiwiki', '-setup', SETUP_BLOG, blog_name, admin_name],
input=input_, env=dict(os.environ, PERL_UNICODE='AS'))
@privileged
def setup_site(site_name: str):
"""Run setup for a site."""
setup_path = os.path.join(WIKI_PATH, site_name + '.setup')
subprocess.run(['ikiwiki', '-setup', setup_path], check=True)
action_utils.run(['ikiwiki', '-setup', setup_path], check=True)
@privileged

View File

@ -9,6 +9,7 @@ import shutil
import subprocess
import time
from plinth import action_utils
from plinth.actions import privileged
DATA_DIR = '/var/lib/infinoted'
@ -105,7 +106,7 @@ def _kill_daemon():
end_time = time.time() + 300
while time.time() < end_time:
try:
subprocess.run(['infinoted', '--kill-daemon'], check=True)
action_utils.run(['infinoted', '--kill-daemon'], check=True)
break
except subprocess.CalledProcessError:
pass
@ -123,19 +124,19 @@ def setup():
with open(SYSTEMD_SERVICE_PATH, 'w', encoding='utf-8') as file_handle:
file_handle.write(SYSTEMD_SERVICE)
subprocess.check_call(['systemctl', 'daemon-reload'])
action_utils.service_daemon_reload()
# Create infinoted group if needed.
try:
grp.getgrnam('infinoted')
except KeyError:
subprocess.run(['addgroup', '--system', 'infinoted'], check=True)
action_utils.run(['addgroup', '--system', 'infinoted'], check=True)
# Create infinoted user if needed.
try:
pwd.getpwnam('infinoted')
except KeyError:
subprocess.run([
action_utils.run([
'adduser', '--system', '--ingroup', 'infinoted', '--home',
DATA_DIR, '--gecos', 'Infinoted collaborative editing server',
'infinoted'
@ -151,7 +152,7 @@ def setup():
try:
# infinoted doesn't have a "create key and exit" mode. Run as
# daemon so we can stop after.
subprocess.run([
action_utils.run([
'infinoted', '--create-key', '--create-certificate',
'--daemonize'
], check=True)

View File

@ -53,7 +53,8 @@ def add_package(file_name: str, temporary_file_path: str):
def _kiwix_manage_add(zim_file: str):
subprocess.check_call(['kiwix-manage', LIBRARY_FILE, 'add', zim_file])
action_utils.run(['kiwix-manage', LIBRARY_FILE, 'add', zim_file],
check=True)
# kiwix-serve doesn't read the library file unless it is restarted.
action_utils.service_try_restart('kiwix-server-freedombox')
@ -97,8 +98,8 @@ def delete_package(zim_id: str):
if book.attrib['id'] != zim_id:
continue
subprocess.check_call(
['kiwix-manage', LIBRARY_FILE, 'remove', zim_id])
action_utils.run(['kiwix-manage', LIBRARY_FILE, 'remove', zim_id],
check=True)
(KIWIX_HOME / book.attrib['path']).unlink()
action_utils.service_try_restart('kiwix-server-freedombox')
return

View File

@ -8,7 +8,6 @@ import os
import pathlib
import re
import shutil
import subprocess
import sys
from typing import Any
@ -28,8 +27,9 @@ WEB_ROOT_PATH = '/var/www/html'
def _get_certificate_expiry(domain: str) -> str:
"""Return the expiry date of a certificate."""
certificate_file = os.path.join(le.LIVE_DIRECTORY, domain, 'cert.pem')
output = subprocess.check_output(
['openssl', 'x509', '-enddate', '-noout', '-in', certificate_file])
output = action_utils.run(
['openssl', 'x509', '-enddate', '-noout', '-in', certificate_file],
check=True).stdout
return output.decode().strip().split('=')[1]
@ -41,7 +41,8 @@ def _get_modified_time(domain: str) -> int:
def _get_validity_status(domain: str) -> str:
"""Return validity status of a certificate; valid, revoked, expired."""
output = subprocess.check_output(['certbot', 'certificates', '-d', domain])
output = action_utils.run(['certbot', 'certificates', '-d', domain],
check=True).stdout
line = output.decode(sys.stdout.encoding)
match = re.search(r'INVALID: (.*)\)', line)
@ -115,7 +116,7 @@ def revoke(domain: str):
if TEST_MODE:
command.append('--staging')
subprocess.run(command, check=True)
action_utils.run(command, check=True)
action_utils.webserver_disable(domain, kind='site')
@ -132,7 +133,7 @@ def obtain(domain: str):
if TEST_MODE:
command.append('--staging')
subprocess.run(command, check=True)
action_utils.run(command, check=True)
@privileged
@ -249,5 +250,5 @@ def _assert_managed_path(module, path):
def delete(domain: str):
"""Disable a domain and delete the certificate."""
command = ['certbot', 'delete', '--non-interactive', '--cert-name', domain]
subprocess.run(command, check=True)
action_utils.run(command, check=True)
action_utils.webserver_disable(domain, kind='site')

View File

@ -7,6 +7,7 @@ import shutil
import subprocess
import tempfile
from plinth import action_utils
from plinth.actions import privileged, secret_str
from plinth.utils import generate_password
@ -26,8 +27,7 @@ def get_php_command():
version = ''
try:
process = subprocess.run(['dpkg', '-s', 'php'], stdout=subprocess.PIPE,
check=True)
process = action_utils.run(['dpkg', '-s', 'php'], check=True)
for line in process.stdout.decode().splitlines():
if line.startswith('Version:'):
version = line.split(':')[-1].split('+')[0].strip()
@ -51,14 +51,15 @@ def setup():
with tempfile.NamedTemporaryFile() as password_file_handle:
password_file_handle.write(password.encode())
password_file_handle.flush()
subprocess.check_call([
action_utils.run([
get_php_command(), install_script, '--confpath=/etc/mediawiki',
'--dbtype=sqlite', '--dbpath=' + data_dir,
'--scriptpath=/mediawiki', '--passfile',
password_file_handle.name, 'Wiki', 'admin'
])
subprocess.run(['chmod', '-R', 'o-rwx', data_dir], check=True)
subprocess.run(['chown', '-R', 'www-data:www-data', data_dir], check=True)
], check=True)
action_utils.run(['chmod', '-R', 'o-rwx', data_dir], check=True)
action_utils.run(['chown', '-R', 'www-data:www-data', data_dir],
check=True)
conf_file = pathlib.Path(CONF_FILE)
if not conf_file.exists():
@ -100,17 +101,17 @@ def change_password(username: str, password: secret_str):
change_password_script = os.path.join(MAINTENANCE_SCRIPTS_DIR,
'changePassword.php')
subprocess.check_call([
action_utils.run([
get_php_command(), change_password_script, '--user', username,
'--password', password
])
], check=True)
@privileged
def update():
"""Run update.php maintenance script when version upgrades happen."""
update_script = os.path.join(MAINTENANCE_SCRIPTS_DIR, 'update.php')
subprocess.check_call([get_php_command(), update_script, '--quick'])
action_utils.run([get_php_command(), update_script, '--quick'], check=True)
def _update_setting(setting_name, setting_line):
@ -178,7 +179,7 @@ def set_default_language(language: str):
# languages.
rebuild_messages_script = os.path.join(MAINTENANCE_SCRIPTS_DIR,
'rebuildmessages.php')
subprocess.check_call([get_php_command(), rebuild_messages_script])
action_utils.run([get_php_command(), rebuild_messages_script], check=True)
@privileged

View File

@ -1,7 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure minidlna server."""
import subprocess
from os import chmod, fdopen, remove, stat
from shutil import move
from tempfile import mkstemp
@ -51,7 +50,7 @@ def setup():
encoding='utf-8') as conf:
conf.write(SYSCTL_CONF)
subprocess.run(['systemctl', 'restart', 'systemd-sysctl'], check=True)
action_utils.run(['systemctl', 'restart', 'systemd-sysctl'], check=True)
@privileged

View File

@ -4,7 +4,6 @@ Configure Mumble server.
"""
import pathlib
import subprocess
import augeas
@ -37,8 +36,8 @@ def check_setup() -> bool:
@privileged
def set_super_user_password(password: secret_str):
"""Set the superuser password with murmurd command."""
subprocess.run(['murmurd', '-readsupw'], input=password.encode(),
stdout=subprocess.DEVNULL, check=False)
action_utils.run(['murmurd', '-readsupw'], input=password.encode(),
check=False)
@privileged

View File

@ -2,7 +2,6 @@
"""Configure Names App."""
import pathlib
import subprocess
import augeas
@ -22,7 +21,7 @@ HOSTS_LOCAL_IP = '127.0.1.1'
@privileged
def set_hostname(hostname: str):
"""Set system hostname using hostnamectl."""
subprocess.run(
action_utils.run(
['hostnamectl', 'set-hostname', '--transient', '--static', hostname],
check=True)
action_utils.service_restart('avahi-daemon')
@ -83,7 +82,7 @@ def domain_delete_all():
def install_resolved():
"""Install systemd-resolved related packages."""
packages = ['systemd-resolved', 'libnss-resolve']
subprocess.run(['dpkg', '--configure', '-a'], check=False)
action_utils.run(['dpkg', '--configure', '-a'], check=False)
with action_utils.apt_hold_freedombox():
action_utils.run_apt_command(['--fix-broken', 'install'])
returncode = action_utils.run_apt_command(['install'] + packages)

View File

@ -29,8 +29,9 @@ def _sort_interfaces(interfaces: list[str]) -> list[str]:
def _get_interfaces() -> dict[str, list[str]]:
"""Return all network interfaces by their type."""
output = subprocess.check_output(
['nmcli', '--terse', '--fields', 'type,device', 'device'])
output = action_utils.run(
['nmcli', '--terse', '--fields', 'type,device', 'device'],
check=True).stdout
interfaces = collections.defaultdict(list)
for line in output.decode().splitlines():
type_, _, interface = line.partition(':')
@ -45,14 +46,15 @@ def _get_interfaces() -> dict[str, list[str]]:
def _add_connection(connection_name: str, interface: str,
remaining_arguments: list[str]):
"""Add an Ethernet/Wi-Fi connection of type regular or shared."""
output = subprocess.check_output(
['nmcli', '--terse', '--fields', 'name,device', 'con', 'show'])
output = action_utils.run(
['nmcli', '--terse', '--fields', 'name,device', 'con', 'show'],
check=True).stdout
lines = output.decode().splitlines()
if f'{connection_name}:{interface}' in lines:
logging.info('Connection %s already exists for device %s, not adding.',
connection_name, interface)
else:
subprocess.run([
action_utils.run([
'nmcli', 'con', 'add', 'con-name', connection_name, 'ifname',
interface
] + remaining_arguments, check=True)
@ -108,8 +110,9 @@ def _set_connection_properties(connection_name: str, properties: dict[str,
str]):
"""Configure property key/values on a connection."""
for key, value in properties.items():
subprocess.run(['nmcli', 'con', 'modify', connection_name, key, value],
check=True)
action_utils.run(
['nmcli', 'con', 'modify', connection_name, key, value],
check=True)
def _configure_wireless_interface(interface: str):

View File

@ -6,10 +6,10 @@ from unittest.mock import patch
from .. import privileged
@patch('subprocess.check_output')
def test_get_interfaces(check_output):
@patch('subprocess.run')
def test_get_interfaces(run):
"""Test returning list of network interfaces in sorted order."""
check_output.return_value = '\n'.join([
run.return_value.stdout = '\n'.join([
'ethernet:ve-fbx-testing',
'ethernet:enp39s0',
'ethernet:enp32s1',

View File

@ -73,13 +73,13 @@ def setup():
def _run_in_container(
*args, capture_output: bool = False, check: bool = True,
*args, check: bool = True,
env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
"""Run a command inside the container."""
env_args = [f'--env={key}={value}' for key, value in (env or {}).items()]
command = ['podman', 'exec', '--user', WWW_DATA_UID
] + env_args + [CONTAINER_NAME] + list(args)
return subprocess.run(command, capture_output=capture_output, check=check)
return action_utils.run(command, check=check)
def _run_occ(*args, **kwargs) -> subprocess.CompletedProcess:
@ -109,8 +109,7 @@ def disable():
def get_override_domain():
"""Return the domain name that Nextcloud is configured to override with."""
try:
domain = _run_occ('config:system:get', 'overwritehost',
capture_output=True)
domain = _run_occ('config:system:get', 'overwritehost')
return domain.stdout.decode().strip()
except subprocess.CalledProcessError:
return None
@ -159,8 +158,7 @@ def get_default_phone_region():
""""Get the value of default_phone_region."""
try:
default_phone_region = _run_occ('config:system:get',
'default_phone_region',
capture_output=True)
'default_phone_region')
return default_phone_region.stdout.decode().strip()
except subprocess.CalledProcessError:
return None
@ -174,7 +172,7 @@ def set_default_phone_region(region: str):
def _database_query(query: str):
"""Run a database query."""
subprocess.run(['mysql'], input=query.encode(), check=True)
action_utils.run(['mysql'], input=query.encode(), check=True)
def _create_database():
@ -239,14 +237,14 @@ def _nextcloud_wait_until_ready():
# obtaining. We are unable to obtain the lock for 5 minutes, fail and stop
# the setup process.
lock_file = _data_path / 'nextcloud-init-sync.lock'
subprocess.run(
action_utils.run(
['flock', '--exclusive', '--wait', '300', lock_file, 'echo'],
check=True)
def _nextcloud_get_status():
"""Return Nextcloud status such installed, in maintenance, etc."""
output = _run_occ('status', '--output=json', capture_output=True)
output = _run_occ('status', '--output=json')
return json.loads(output.stdout)
@ -281,8 +279,7 @@ def _configure_ldap():
# Check if LDAP has already been configured. This is necessary because
# if the setup proccess is rerun when updating the FredomBox app another
# redundant LDAP config would be created.
output = _run_occ('ldap:test-config', 's01', capture_output=True,
check=False)
output = _run_occ('ldap:test-config', 's01', check=False)
if 'Invalid configID' in output.stdout.decode():
_run_occ('ldap:create-empty-config')
@ -362,7 +359,7 @@ def dump_database():
with _maintenance_mode():
with DB_BACKUP_FILE.open('w', encoding='utf-8') as file_handle:
subprocess.run([
action_utils.run([
'mysqldump', '--add-drop-database', '--add-drop-table',
'--add-drop-trigger', '--single-transaction',
'--default-character-set=utf8mb4', '--user', 'root',
@ -374,11 +371,11 @@ def dump_database():
def restore_database():
"""Restore database from file."""
with DB_BACKUP_FILE.open('r', encoding='utf-8') as file_handle:
subprocess.run(['mysql', '--user', 'root'], stdin=file_handle,
check=True)
action_utils.run(['mysql', '--user', 'root'], stdin=file_handle,
check=True)
subprocess.run(['redis-cli', '-n',
str(REDIS_DB), 'FLUSHDB', 'SYNC'], check=False)
action_utils.run(['redis-cli', '-n',
str(REDIS_DB), 'FLUSHDB', 'SYNC'], check=False)
_set_database_privileges(_get_database_password())
@ -405,8 +402,7 @@ def _get_database_password():
code = 'if (file_exists("/var/www/html/config/config.php")) {' \
'include_once("/var/www/html/config/config.php");' \
'print($CONFIG["dbpassword"] ?? ""); }'
return _run_in_container('php', '-r', code,
capture_output=True).stdout.decode().strip()
return _run_in_container('php', '-r', code).stdout.decode().strip()
def _create_redis_config():

View File

@ -104,15 +104,15 @@ def _setup_firewall():
'firewall-cmd', '--zone', 'internal',
'--{}-interface'.format(operation), interface
]
subprocess.call(command)
subprocess.call(command + ['--permanent'])
action_utils.run(command, check=False)
action_utils.run(command + ['--permanent'], check=False)
def _is_tunplus_enabled():
"""Return whether tun+ interface is already added."""
try:
process = subprocess.run(
process = action_utils.run(
['firewall-cmd', '--zone', 'internal', '--list-interfaces'],
stdout=subprocess.PIPE, check=True)
check=True)
return 'tun+' in process.stdout.decode().strip().split()
except subprocess.CalledProcessError:
return True # Safer
@ -135,8 +135,8 @@ def _setup_firewall():
def _run_easy_rsa(args):
"""Execute easy-rsa command with some default arguments."""
return subprocess.run(['/usr/share/easy-rsa/easyrsa'] + args,
cwd=KEYS_DIRECTORY, check=True)
return action_utils.run(['/usr/share/easy-rsa/easyrsa'] + args,
cwd=KEYS_DIRECTORY, check=True)
def _write_easy_rsa_config():
@ -162,9 +162,9 @@ def _is_renewable(cert_name):
if not cert_path.exists():
return False
process = subprocess.run(
process = action_utils.run(
['openssl', 'x509', '-noout', '-enddate', '-in',
str(cert_path)], check=True, stdout=subprocess.PIPE)
str(cert_path)], check=True)
date_string = process.stdout.decode().strip().partition('=')[2]
cert_expiry_time = datetime.datetime.strptime(date_string,
'%b %d %H:%M:%S %Y GMT')

View File

@ -1,18 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Shutdown/restart the system."""
import subprocess
from plinth import action_utils
from plinth.actions import privileged
@privileged
def restart():
"""Restart the system."""
subprocess.call('reboot')
action_utils.run('reboot', check=False)
@privileged
def shutdown():
"""Shut down the system."""
subprocess.call(['shutdown', 'now'])
action_utils.run(['shutdown', 'now'], check=False)

View File

@ -7,6 +7,7 @@ import pathlib
import shutil
import subprocess
from plinth import action_utils
from plinth.actions import privileged
DEFAULT_FILE = '/etc/default/samba'
@ -51,12 +52,13 @@ CONF = r'''
def _close_share(share_name):
"""Disconnect all samba users who are connected to the share."""
subprocess.check_call(['smbcontrol', 'smbd', 'close-share', share_name])
action_utils.run(['smbcontrol', 'smbd', 'close-share', share_name],
check=True)
def _conf_command(parameters, **kwargs):
"""Run samba configuration registry command."""
subprocess.check_call(['net', 'conf'] + parameters, **kwargs)
action_utils.run(['net', 'conf'] + parameters, check=True, **kwargs)
def _create_share(mount_point, share_type, windows_filesystem=False):
@ -103,7 +105,7 @@ def _create_share_name(mount_point):
def _define_open_share(name, path, windows_filesystem=False):
"""Define an open samba share."""
try:
_conf_command(['delshare', name], stderr=subprocess.DEVNULL)
_conf_command(['delshare', name])
except subprocess.CalledProcessError:
pass
_conf_command(['addshare', name, path, 'writeable=y', 'guest_ok=y'])
@ -115,7 +117,7 @@ def _define_open_share(name, path, windows_filesystem=False):
def _define_group_share(name, path, windows_filesystem=False):
"""Define a group samba share."""
try:
_conf_command(['delshare', name], stderr=subprocess.DEVNULL)
_conf_command(['delshare', name])
except subprocess.CalledProcessError:
pass
_conf_command(['addshare', name, path, 'writeable=y', 'guest_ok=n'])
@ -128,7 +130,7 @@ def _define_group_share(name, path, windows_filesystem=False):
def _define_homes_share(name, path):
"""Define a samba share for private homes."""
try:
_conf_command(['delshare', name], stderr=subprocess.DEVNULL)
_conf_command(['delshare', name])
except subprocess.CalledProcessError:
pass
userpath = os.path.join(path, '%u')
@ -153,7 +155,7 @@ def _get_mount_point(path):
def _get_shares() -> list[dict[str, str]]:
"""Get shares."""
shares = []
output = subprocess.check_output(['net', 'conf', 'list'])
output = action_utils.run(['net', 'conf', 'list'], check=True).stdout
config = configparser.RawConfigParser()
config.read_string(output.decode())
for name in config.sections():
@ -198,8 +200,8 @@ def _set_open_share_permissions(directory):
file_path = os.path.join(root, file)
shutil.chown(file_path, group='freedombox-share')
os.chmod(file_path, 0o0664)
subprocess.check_call(['setfacl', '-Rm', 'g::rwX', directory])
subprocess.check_call(['setfacl', '-Rdm', 'g::rwX', directory])
action_utils.run(['setfacl', '-Rm', 'g::rwX', directory], check=True)
action_utils.run(['setfacl', '-Rdm', 'g::rwX', directory], check=True)
def _use_config_file(conf_file):
@ -229,8 +231,8 @@ def _set_share_permissions(directory):
file_path = os.path.join(root, file)
shutil.chown(file_path, group='freedombox-share')
os.chmod(file_path, 0o0664)
subprocess.check_call(['setfacl', '-Rm', 'g::rwX', directory])
subprocess.check_call(['setfacl', '-Rdm', 'g::rwX', directory])
action_utils.run(['setfacl', '-Rm', 'g::rwX', directory], check=True)
action_utils.run(['setfacl', '-Rdm', 'g::rwX', directory], check=True)
@privileged
@ -270,7 +272,7 @@ def get_shares() -> list[dict[str, str]]:
@privileged
def get_users() -> list[str]:
"""Get users from Samba database."""
output = subprocess.check_output(['pdbedit', '-L']).decode()
output = action_utils.run(['pdbedit', '-L'], check=True).stdout.decode()
samba_users = [line.split(':')[0] for line in output.split()]
return samba_users

View File

@ -4,7 +4,6 @@
import os
import pathlib
import signal
import subprocess
import augeas
import dbus
@ -21,13 +20,13 @@ def setup(old_version: int):
"""Configure snapper."""
# Check if root config exists.
command = ['snapper', 'list-configs']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
process = action_utils.run(command, check=True)
output = process.stdout.decode()
# Create root config if needed.
if 'root' not in output:
command = ['snapper', 'create-config', '/']
subprocess.run(command, check=True)
action_utils.run(command, check=True)
if old_version and old_version <= 4:
_remove_fstab_entry('/')
@ -76,7 +75,7 @@ def _migrate_config_from_version_3():
'EMPTY_PRE_POST_MIN_AGE=0',
'FREE_LIMIT=0.3',
]
subprocess.run(command, check=True)
action_utils.run(command, check=True)
def _set_default_config():
@ -98,7 +97,7 @@ def _set_default_config():
'EMPTY_PRE_POST_MIN_AGE=0',
'FREE_LIMIT=0.3',
]
subprocess.run(command, check=True)
action_utils.run(command, check=True)
def _remove_fstab_entry(mount_point):
@ -137,16 +136,15 @@ def _remove_fstab_entry(mount_point):
def _systemd_path_escape(path):
"""Escape a string using systemd path rules."""
process = subprocess.run(['systemd-escape', '--path', path],
stdout=subprocess.PIPE, check=True)
process = action_utils.run(['systemd-escape', '--path', path], check=True)
return process.stdout.decode().strip()
def _get_subvolume_path(mount_point):
"""Return the subvolume path for .snapshots in a filesystem."""
# -o causes the list of subvolumes directly under the given mount point
process = subprocess.run(['btrfs', 'subvolume', 'list', '-o', mount_point],
stdout=subprocess.PIPE, check=True)
process = action_utils.run(
['btrfs', 'subvolume', 'list', '-o', mount_point], check=True)
for line in process.stdout.decode().splitlines():
entry = line.split()
@ -223,8 +221,7 @@ def _parse_number(number):
@privileged
def list_() -> list[dict[str, str]]:
"""List snapshots."""
process = subprocess.run(['snapper', 'list'], stdout=subprocess.PIPE,
check=True)
process = action_utils.run(['snapper', 'list'], check=True)
lines = process.stdout.decode().splitlines()
keys = ('number', 'is_default', 'is_active', 'type', 'pre_number', 'date',
@ -246,7 +243,7 @@ def list_() -> list[dict[str, str]]:
def _get_default_snapshot():
"""Return the default snapshot by looking at default subvolume."""
command = ['btrfs', 'subvolume', 'get-default', '/']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
process = action_utils.run(command, check=True)
output = process.stdout.decode()
output_parts = output.split()
@ -277,26 +274,26 @@ def disable_apt_snapshot(state: str):
def create():
"""Create snapshot."""
command = ['snapper', 'create', '--description', 'manually created']
subprocess.run(command, check=True)
action_utils.run(command, check=True)
@privileged
def delete(number: str):
"""Delete a snapshot by number."""
command = ['snapper', 'delete', number]
subprocess.run(command, check=True)
action_utils.run(command, check=True)
@privileged
def set_config(config: list[str]):
"""Set snapper configuration."""
command = ['snapper', 'set-config'] + config
subprocess.run(command, check=True)
action_utils.run(command, check=True)
def _get_config():
command = ['snapper', 'get-config']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
process = action_utils.run(command, check=True)
lines = process.stdout.decode().splitlines()
config = {}
for line in lines[2:]:
@ -345,4 +342,4 @@ def rollback(number: str):
# behavior when a snapshot number to rollback to is provided is the
# behavior that we desire.
command = ['snapper', '--ambit', 'classic', 'rollback', number]
subprocess.run(command, check=True)
action_utils.run(command, check=True)

View File

@ -4,10 +4,9 @@
import pathlib
import re
import shutil
import subprocess
import tempfile
from plinth import utils
from plinth import action_utils, utils
from plinth.actions import privileged
from plinth.db import postgres
from plinth.modules.email.privileged.domain import \
@ -144,8 +143,8 @@ def set_domain(domain: str):
def _get_config_value(key: str) -> str:
"""Return the value of a property from the configuration file."""
process = subprocess.run(['plget', key], input=CONFIG_FILE.read_bytes(),
stdout=subprocess.PIPE, check=True)
process = action_utils.run(['plget', key], input=CONFIG_FILE.read_bytes(),
check=True)
return process.stdout.decode().strip()
@ -154,7 +153,7 @@ def _set_config_value(key: str, value: str):
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(f'{{\n{key} = "{value}";\n}}'.encode('utf-8'))
temp_file.close()
subprocess.run(['plmerge', CONFIG_FILE, temp_file.name], check=True)
action_utils.run(['plmerge', CONFIG_FILE, temp_file.name], check=True)
pathlib.Path(temp_file.name).unlink()

View File

@ -7,7 +7,6 @@ import pathlib
import pwd
import shutil
import stat
import subprocess
import augeas
@ -101,7 +100,7 @@ def set_keys(user: str, keys: str, auth_user: str, auth_password: secret_str):
ssh_folder = os.path.join(get_user_homedir(user), '.ssh')
key_file_path = os.path.join(ssh_folder, 'authorized_keys')
subprocess.check_call(['mkhomedir_helper', user])
action_utils.run(['mkhomedir_helper', user], check=True)
if not os.path.exists(ssh_folder):
os.makedirs(ssh_folder)

View File

@ -46,7 +46,7 @@ def _move_gpt_second_header(device):
"""
command = ['sgdisk', '--move-second-header', device]
try:
subprocess.run(command, check=True)
action_utils.run(command, check=True)
except subprocess.CalledProcessError:
raise RuntimeError('Error moving GPT second header to the end')
@ -65,12 +65,12 @@ def _resize_partition(device, requested_partition, free_space):
'B', 'resizepart', requested_partition['number']
]
try:
subprocess.run(command, check=True)
action_utils.run(command, check=True)
except subprocess.CalledProcessError:
try:
input_text = 'yes\n' + str(free_space['end'])
subprocess.run(fallback_command, check=True,
input=input_text.encode())
action_utils.run(fallback_command, check=True,
input=input_text.encode())
except subprocess.CalledProcessError as exception:
raise RuntimeError(f'Error expanding partition: {exception}')
@ -90,8 +90,7 @@ def _resize_ext4(device, requested_partition, _free_space, _mount_point):
requested_partition['number'])
try:
command = ['resize2fs', partition_device]
subprocess.run(command, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=True)
action_utils.run(command, check=True)
except subprocess.CalledProcessError as exception:
raise RuntimeError(f'Error expanding filesystem: {exception}')
@ -100,7 +99,7 @@ def _resize_btrfs(_device, _requested_partition, _free_space, mount_point='/'):
"""Resize a btrfs file system inside a partition."""
try:
command = ['btrfs', 'filesystem', 'resize', 'max', mount_point]
subprocess.run(command, stdout=subprocess.DEVNULL, check=True)
action_utils.run(command, check=True)
except subprocess.CalledProcessError as exception:
raise RuntimeError(f'Error expanding filesystem: {exception}')
@ -167,7 +166,7 @@ def _get_partitions_and_free_spaces(device, partition_number):
command = [
'parted', '--machine', '--script', device, 'unit', 'B', 'print', 'free'
]
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
process = action_utils.run(command, check=True)
requested_partition = None
free_spaces = []
@ -215,7 +214,7 @@ def mount(block_device: str):
UDISKS_FILESYSTEM_SHARED=1 by writing a udev rule.
"""
subprocess.run([
action_utils.run([
'udisksctl', 'mount', '--block-device', block_device,
'--no-user-interaction'
], check=True)
@ -325,7 +324,7 @@ def usage_info() -> str:
'df', '--exclude-type=tmpfs', '--exclude-type=devtmpfs',
'--block-size=1', '--output=source,fstype,size,used,avail,pcent,target'
]
return subprocess.check_output(command).decode()
return action_utils.run(command, check=False).stdout.decode()
@privileged

View File

@ -5,7 +5,6 @@ import grp
import os
import pwd
import shutil
import subprocess
import time
import augeas
@ -37,13 +36,13 @@ def setup():
try:
grp.getgrnam('syncthing')
except KeyError:
subprocess.run(['addgroup', '--system', 'syncthing'], check=True)
action_utils.run(['addgroup', '--system', 'syncthing'], check=True)
# Create syncthing user if needed.
try:
pwd.getpwnam('syncthing')
except KeyError:
subprocess.run([
action_utils.run([
'adduser', '--system', '--ingroup', 'syncthing', '--home',
DATA_DIR, '--gecos', 'Syncthing file synchronization server',
'syncthing'

View File

@ -8,7 +8,6 @@ import pathlib
import re
import shutil
import socket
import subprocess
import time
from typing import Any
@ -54,7 +53,7 @@ def _first_time_setup():
"""Setup Tor configuration for the first time setting defaults."""
logger.info('Performing first time setup for Tor')
subprocess.run(['tor-instance-create', INSTANCE_NAME], check=True)
action_utils.run(['tor-instance-create', INSTANCE_NAME], check=True)
# Remove line starting with +SocksPort, since our augeas lens
# doesn't handle it correctly.

View File

@ -4,7 +4,6 @@
import logging
import os
import shutil
import subprocess
from typing import Any
import augeas
@ -31,7 +30,7 @@ def setup():
# Mask the service to prevent re-enabling it by the Tor master service.
action_utils.service_mask('tor@default')
subprocess.run(['tor-instance-create', INSTANCE_NAME], check=True)
action_utils.run(['tor-instance-create', INSTANCE_NAME], check=True)
# Remove line starting with +SocksPort, since our augeas lens
# doesn't handle it correctly.

View File

@ -351,6 +351,15 @@ def is_backports_enabled():
return os.path.exists(privileged.BACKPORTS_SOURCES_LIST)
def get_current_release():
"""Return current release and codename as a tuple."""
output = action_utils.run(
['lsb_release', '--release', '--codename', '--short'],
check=True).stdout.decode().strip()
lines = output.split('\n')
return lines[0], lines[1]
def is_backports_current():
"""Return whether backports are enabled for the current release."""
if not is_backports_enabled():

View File

@ -5,7 +5,6 @@ import contextlib
import datetime
import logging
import pathlib
import subprocess
from datetime import timezone
from typing import Generator
@ -73,7 +72,7 @@ distribution_info: dict = {
def _apt_run(arguments: list[str]):
"""Run an apt command and ensure that output is written to stdout."""
returncode = action_utils.run_apt_command(arguments, stdout=None)
returncode = action_utils.run_apt_command(arguments)
if returncode:
raise RuntimeError(
f'Apt command failed with return code: {returncode}')
@ -218,11 +217,11 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]:
try:
logger.info('Taking a snapshot before dist upgrade...')
command = ['snapper', 'create', '--description', 'before dist-upgrade']
subprocess.run(command, check=True)
action_utils.run(command, check=True)
aug = snapshot_module.load_augeas()
if snapshot_module.is_apt_snapshots_enabled(aug):
logger.info('Disabling apt snapshots during dist upgrade...')
subprocess.run([
action_utils.run([
'/usr/bin/freedombox-cmd',
'snapshot',
'disable_apt_snapshot',
@ -235,7 +234,7 @@ def _snapshot_run_and_disable() -> Generator[None, None, None]:
finally:
if reenable:
logger.info('Re-enabling apt snapshots...')
subprocess.run([
action_utils.run([
'/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'
], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True)
else:
@ -303,7 +302,7 @@ def _apt_update():
def _apt_fix():
"""Try to fix any problems with apt/dpkg before the upgrade."""
logger.info('Fixing any broken apt/dpkg states...')
subprocess.run(['dpkg', '--configure', '-a'], check=False)
action_utils.run(['dpkg', '--configure', '-a'], check=False)
_apt_run(['--fix-broken', 'install'])
@ -341,7 +340,7 @@ def _unattended_upgrades_run():
To handle upgrading the freedombox package.
"""
logger.info('Running unattended-upgrade...')
subprocess.run(['unattended-upgrade', '--verbose'], check=False)
action_utils.run(['unattended-upgrade', '--verbose'], check=False)
def _freedombox_restart():
@ -360,7 +359,7 @@ def _trigger_on_complete():
# file will not be possible. For that, we need to launch a new process with
# a different systemd service (which does not have the bind mounts).
logger.info('Triggering on-complete to commit sources.lists')
subprocess.run([
action_utils.run([
'systemd-run', '--unit=freedombox-dist-upgrade-on-complete',
'--description=Finish up upgrade to new stable Debian release',
'/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete',
@ -417,7 +416,7 @@ def start_service():
'--property=KillMode=process', '--property=TimeoutSec=72hr',
f'--property=BindPaths={temp_sources_list}:{sources_list}'
]
subprocess.run(['systemd-run'] + args + [
action_utils.run(['systemd-run'] + args + [
'systemd-inhibit', '/usr/bin/freedombox-cmd', 'upgrades',
'dist_upgrade', '--no-args'
], check=True)

View File

@ -7,6 +7,7 @@ import pathlib
import re
import subprocess
from plinth import action_utils
from plinth.action_utils import (apt_hold_flag, apt_unhold_freedombox,
is_package_manager_busy, run_apt_command,
service_is_running)
@ -127,17 +128,17 @@ def release_held_packages():
'holds.')
return
output = subprocess.check_output(['apt-mark', 'showhold']).decode().strip()
output = action_utils.run(['apt-mark', 'showhold'],
check=True).stdout.decode().strip()
holds = output.split('\n')
logger.info('Releasing package holds: %s', holds)
subprocess.run(['apt-mark', 'unhold', *holds], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=True)
action_utils.run(['apt-mark', 'unhold', *holds], check=True)
@privileged
def run():
"""Run unattended-upgrades."""
subprocess.run(['dpkg', '--configure', '-a'], check=False)
action_utils.run(['dpkg', '--configure', '-a'], check=False)
run_apt_command(['--fix-broken', 'install'])
_release_held_freedombox()

View File

@ -7,7 +7,7 @@ import re
import subprocess
from datetime import datetime as datetime_original
from datetime import timezone
from unittest.mock import call, patch
from unittest.mock import Mock, call, patch
import pytest
@ -24,7 +24,7 @@ def test_apt_run(run):
distupgrade._apt_run(args)
assert run.call_args.args == \
(['apt-get', '--assume-yes', '--quiet=2'] + args,)
assert not run.call_args.kwargs['stdout']
assert run.call_args.kwargs['stdout'] == subprocess.PIPE
run.return_value.returncode = 10
with pytest.raises(RuntimeError):
@ -219,7 +219,7 @@ def test_snapshot_run_and_disable(is_supported, is_apt_snapshots_enabled, run):
with distupgrade._snapshot_run_and_disable():
assert run.call_args_list == [
call(['snapper', 'create', '--description', 'before dist-upgrade'],
check=True)
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
]
run.reset_mock()
@ -230,16 +230,18 @@ def test_snapshot_run_and_disable(is_supported, is_apt_snapshots_enabled, run):
with distupgrade._snapshot_run_and_disable():
assert run.call_args_list == [
call(['snapper', 'create', '--description', 'before dist-upgrade'],
check=True),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True),
call([
'/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'
], input=b'{"args": ["yes"], "kwargs": {}}', check=True)
], input=b'{"args": ["yes"], "kwargs": {}}',
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
]
run.reset_mock()
assert run.call_args_list == [
call(['/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'],
input=b'{"args": ["no"], "kwargs": {}}', check=True)
input=b'{"args": ["no"], "kwargs": {}}', stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True)
]
@ -262,40 +264,51 @@ def test_services_disable(service_is_running, service_disable, service_enable):
@patch('subprocess.run')
@patch('subprocess.check_call')
@patch('subprocess.check_output')
def test_apt_hold_packages(check_output, check_call, run, tmp_path):
def test_apt_hold_packages(run, tmp_path):
"""Test that holding apt packages works."""
def _run(command, **kwargs):
if 'showhold' in command:
return Mock(stdout=False)
return Mock(returncode=0)
hold_flag = tmp_path / 'flag'
run.return_value.returncode = 0
run.side_effect = _run
with patch('plinth.action_utils.apt_hold_flag', hold_flag), \
patch('plinth.modules.upgrades.distupgrade.PACKAGES_WITH_PROMPTS',
['package1', 'package2']):
check_output.return_value = False
with distupgrade._apt_hold_packages():
assert hold_flag.exists()
assert hold_flag.stat().st_mode & 0o117 == 0
expected_call = [call(['apt-mark', 'hold', 'freedombox'])]
assert check_call.call_args_list == expected_call
expected_calls = [
call(['apt-mark', 'hold', 'package1'], check=False),
call(['apt-mark', 'hold', 'package2'], check=False)
call(['apt-mark', 'showhold', 'freedombox'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE),
call(['apt-mark', 'hold', 'freedombox'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE),
call(['apt-mark', 'showhold', 'package1'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True),
call(['apt-mark', 'hold', 'package1'], check=False,
stdout=subprocess.PIPE, stderr=subprocess.PIPE),
call(['apt-mark', 'showhold', 'package2'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True),
call(['apt-mark', 'hold', 'package2'], check=False,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
]
assert run.call_args_list == expected_calls
check_call.reset_mock()
assert run.mock_calls == expected_calls
run.reset_mock()
expected_call = [
call(['apt-mark', 'unhold', 'freedombox'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False)
call(['apt-mark', 'unhold', 'package1'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True),
call(['apt-mark', 'unhold', 'package2'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True),
call(['apt-mark', 'unhold', 'freedombox'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=False),
]
assert run.call_args_list == expected_call
expected_calls = [
call(['apt-mark', 'unhold', 'package1']),
call(['apt-mark', 'unhold', 'package2'])
]
assert check_call.call_args_list == expected_calls
@patch('plinth.action_utils.debconf_set_selections')
@ -340,7 +353,8 @@ def test_apt_fix(run, apt_run):
"""Test that apt fixes work."""
distupgrade._apt_fix()
assert run.call_args_list == [
call(['dpkg', '--configure', '-a'], check=False)
call(['dpkg', '--configure', '-a'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=False)
]
assert apt_run.call_args_list == [call(['--fix-broken', 'install'])]
@ -365,7 +379,9 @@ def test_apt_full_upgrade(apt_run):
def test_unatteneded_upgrades_run(run):
"""Test that running unattended upgrades works."""
distupgrade._unattended_upgrades_run()
run.assert_called_with(['unattended-upgrade', '--verbose'], check=False)
run.assert_called_with(['unattended-upgrade', '--verbose'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False)
@patch('plinth.action_utils.service_restart')
@ -384,7 +400,7 @@ def test_trigger_on_complete(run):
'--description=Finish up upgrade to new stable Debian release',
'/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete',
'--no-args'
], check=True)
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
def test_on_complete(tmp_path):

View File

@ -64,10 +64,10 @@ deb http://deb.debian.org/debian trixie main
assert utils.get_sources_list_codename() == 'testing'
@patch('subprocess.check_output')
def test_get_current_release(check_output):
@patch('subprocess.run')
def test_get_current_release(run):
"""Test that getting current release works."""
check_output.return_value = b'test-release\ntest-codename\n\n'
run.return_value.stdout = b'test-release\ntest-codename\n\n'
assert utils.get_current_release() == ('test-release', 'test-codename')

View File

@ -3,10 +3,10 @@
import pathlib
import re
import subprocess
import augeas
from plinth import action_utils
from plinth.modules.apache.components import check_url
RELEASE_FILE_URL = \
@ -23,7 +23,7 @@ def check_auto() -> bool:
'apt-config', 'shell', 'UpdateInterval',
'APT::Periodic::Update-Package-Lists'
]
output = subprocess.check_output(arguments).decode()
output = action_utils.run(arguments, check=True).stdout.decode()
update_interval = 0
match = re.match(r"UpdateInterval='(.*)'", output)
if match:
@ -62,7 +62,7 @@ def is_release_file_available(protocol: str, dist: str,
def is_sufficient_free_space() -> bool:
"""Return whether there is sufficient free space for dist upgrade."""
output = subprocess.check_output(['df', '--output=avail', '/'])
output = action_utils.run(['df', '--output=avail', '/'], check=True).stdout
free_space = int(output.decode().split('\n')[1])
return free_space >= DIST_UPGRADE_REQUIRED_FREE_SPACE
@ -100,9 +100,9 @@ def get_sources_list_codename() -> str | None:
def get_current_release():
"""Return current release and codename as a tuple."""
output = subprocess.check_output(
['lsb_release', '--release', '--codename',
'--short']).decode().strip()
output = action_utils.run(
['lsb_release', '--release', '--codename', '--short'],
check=True).stdout.decode().strip()
lines = output.split('\n')
return lines[0], lines[1]

View File

@ -9,6 +9,7 @@ from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop
from plinth import action_utils
from plinth import app as app_module
from plinth import cfg, menu
from plinth.config import DropinConfigs
@ -127,8 +128,9 @@ def _diagnose_ldap_entry(search_item: str) -> DiagnosticCheck:
result = Result.FAILED
try:
output = subprocess.check_output(
['ldapsearch', '-LLL', '-x', '-b', 'dc=thisbox', search_item])
output = action_utils.run(
['ldapsearch', '-LLL', '-x', '-b', 'dc=thisbox', search_item],
check=True).stdout
if search_item in output.decode():
result = Result.PASSED
except subprocess.CalledProcessError:

View File

@ -68,7 +68,7 @@ def first_setup():
def setup():
"""Setup LDAP."""
# Update pam config for mkhomedir.
subprocess.run(['pam-auth-update', '--package'], check=True)
action_utils.run(['pam-auth-update', '--package'], check=True)
_configure_ldapscripts()
@ -145,10 +145,10 @@ def _create_organizational_unit(unit):
"""Create an organizational unit in LDAP."""
distinguished_name = 'ou={unit},dc=thisbox'.format(unit=unit)
try:
subprocess.run([
action_utils.run([
'ldapsearch', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///', '-s',
'base', '-b', distinguished_name, '(objectclass=*)'
], stdout=subprocess.DEVNULL, check=True)
], check=True)
return # Already exists
except subprocess.CalledProcessError:
input = '''
@ -156,18 +156,18 @@ dn: ou={unit},dc=thisbox
objectClass: top
objectClass: organizationalUnit
ou: {unit}'''.format(unit=unit)
subprocess.run(['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
input=input.encode(), stdout=subprocess.DEVNULL,
check=True)
action_utils.run(
['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
input=input.encode(), check=True)
def _setup_admin():
"""Remove LDAP admin password and Allow root to modify the users."""
process = subprocess.run([
process = action_utils.run([
'ldapsearch', '-Q', '-L', '-L', '-L', '-Y', 'EXTERNAL', '-H',
'ldapi:///', '-s', 'base', '-b', 'olcDatabase={1}mdb,cn=config',
'(objectclass=*)', 'olcRootDN', 'olcRootPW'
], check=True, stdout=subprocess.PIPE)
], check=True)
ldap_object = {}
for line in process.stdout.decode().splitlines():
if line:
@ -175,18 +175,18 @@ def _setup_admin():
ldap_object[line[0]] = line[1]
if 'olcRootPW' in ldap_object:
subprocess.run(
action_utils.run(
['ldapmodify', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
check=True, stdout=subprocess.DEVNULL, input=b'''
check=True, input=b'''
dn: olcDatabase={1}mdb,cn=config
changetype: modify
delete: olcRootPW''')
root_dn = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
if ldap_object['olcRootDN'] != root_dn:
subprocess.run(
action_utils.run(
['ldapmodify', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
check=True, stdout=subprocess.DEVNULL, input=b'''
check=True, input=b'''
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcRootDN
@ -205,9 +205,9 @@ def _setup_ldap_ppolicy() -> bool:
"""
# Load ppolicy module
try:
subprocess.run(
action_utils.run(
['ldapmodify', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
check=True, stdout=subprocess.DEVNULL, input=b'''
check=True, input=b'''
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
@ -218,18 +218,19 @@ olcModuleLoad: ppolicy''')
# Add namedobject schema needed for 'objectClass: namedPolicy'.
try:
subprocess.run([
action_utils.run([
'ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///', '-f',
'/etc/ldap/schema/namedobject.ldif'
], check=True, stdout=subprocess.DEVNULL)
], check=True)
except subprocess.CalledProcessError as error:
if error.returncode != 80: # Schema already added
raise
# Set up default password policy
try:
subprocess.run(['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
check=True, stdout=subprocess.DEVNULL, input=b'''
action_utils.run(
['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'], check=True,
input=b'''
dn: cn=DefaultPPolicy,ou=policies,dc=thisbox
cn: DefaultPPolicy
objectClass: pwdPolicy
@ -243,8 +244,9 @@ pwdLockout: TRUE''')
# Make DefaultPPolicy as a default ppolicy overlay
try:
subprocess.run(['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'],
check=True, stdout=subprocess.DEVNULL, input=b'''
action_utils.run(
['ldapadd', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///'], check=True,
input=b'''
dn: olcOverlay={0}ppolicy,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
@ -329,22 +331,22 @@ def get_nslcd_config() -> dict[str, str]:
def _get_samba_users():
"""Get users from the Samba user database."""
# 'pdbedit -L' is better for listing users but is installed only with samba
stdout = subprocess.check_output(
['tdbdump', '/var/lib/samba/private/passdb.tdb']).decode()
stdout = action_utils.run(['tdbdump', '/var/lib/samba/private/passdb.tdb'],
check=True).stdout.decode()
return re.findall(r'USER_(.*)\\0', stdout)
def _delete_samba_user(username):
"""Delete a Samba user."""
if username in _get_samba_users():
subprocess.check_call(['smbpasswd', '-x', username])
action_utils.run(['smbpasswd', '-x', username], check=True)
_disconnect_samba_user(username)
def _disconnect_samba_user(username):
"""Disconnect a Samba user."""
try:
subprocess.check_call(['pkill', '-U', username, 'smbd'])
action_utils.run(['pkill', '-U', username, 'smbd'], check=True)
except subprocess.CalledProcessError as error:
if error.returncode != 1:
raise
@ -352,7 +354,8 @@ def _disconnect_samba_user(username):
def _get_user_home(username):
"""Return the user home directory."""
output = subprocess.check_output(['getent', 'passwd', username], text=True)
output = action_utils.run(['getent', 'passwd', username],
check=True).stdout.decode()
return pathlib.Path(output.split(':')[5])
@ -453,7 +456,7 @@ def rename_user(old_username: str, new_username: str):
def _set_user_password(username, password):
"""Set a user's password."""
process = _run(['slappasswd', '-s', password], stdout=subprocess.PIPE)
process = _run(['slappasswd', '-s', password])
password = process.stdout.decode().strip()
_run(['ldapsetpasswd', username, password])
@ -463,9 +466,9 @@ def _set_samba_user(username, password):
If a user already exists, update password.
"""
proc = subprocess.run(['smbpasswd', '-a', '-s', username],
input='{0}\n{0}\n'.format(password).encode(),
stderr=subprocess.PIPE, check=False)
proc = action_utils.run(['smbpasswd', '-a', '-s', username],
input='{0}\n{0}\n'.format(password).encode(),
check=False)
if proc.returncode != 0:
raise RuntimeError('Unable to add Samba user: ', proc.stderr)
@ -489,11 +492,11 @@ def _get_admin_users():
admin_users = []
try:
output = subprocess.check_output([
output = action_utils.run([
'ldapsearch', '-LLL', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///',
'-o', 'ldif-wrap=no', '-s', 'base', '-b',
'cn=admin,ou=groups,dc=thisbox', 'memberUid'
]).decode()
], check=True).stdout.decode()
except subprocess.CalledProcessError as error:
if error.returncode == 32:
# no entries found
@ -511,7 +514,7 @@ def _get_admin_users():
def _get_user_ids(username: str) -> str | None:
"""Get user information in format like `id` command."""
try:
process = _run(['ldapid', username], stdout=subprocess.PIPE)
process = _run(['ldapid', username])
except subprocess.CalledProcessError as error:
if error.returncode == 1:
# User doesn't exist
@ -530,7 +533,7 @@ def _user_exists(username):
def _get_group_users(groupname):
"""Return list of members in the group."""
try:
process = _run(['ldapgid', '-P', groupname], stdout=subprocess.PIPE)
process = _run(['ldapgid', '-P', groupname])
except subprocess.CalledProcessError:
return [] # Group does not exist
@ -677,14 +680,14 @@ def set_user_status(username: str, status: str, auth_user: str,
# Set user status in Samba password database
if username in _get_samba_users():
subprocess.check_call(['smbpasswd', smbpasswd_flag, username])
action_utils.run(['smbpasswd', smbpasswd_flag, username], check=True)
_flush_cache()
if status == 'inactive':
# Kill all user processes. This includes disconnectiong ssh, samba and
# cockpit sessions.
subprocess.run(['pkill', '--signal', 'KILL', '--uid', username])
action_utils.run(['pkill', '--signal', 'KILL', '--uid', username])
def _upgrade_inactivate_users(usernames: list[str]):
@ -695,7 +698,7 @@ def _upgrade_inactivate_users(usernames: list[str]):
_flush_cache()
for username in usernames:
subprocess.run(['pkill', '--signal', 'KILL', '--uid', username])
action_utils.run(['pkill', '--signal', 'KILL', '--uid', username])
def _flush_cache():
@ -706,6 +709,4 @@ def _flush_cache():
def _run(arguments, check=True, **kwargs):
"""Run a command. Check return code and suppress output by default."""
env = dict(os.environ, LDAPSCRIPTS_CONF=LDAPSCRIPTS_CONF)
kwargs['stdout'] = kwargs.get('stdout', subprocess.DEVNULL)
kwargs['stderr'] = kwargs.get('stderr', subprocess.DEVNULL)
return subprocess.run(arguments, env=env, check=check, **kwargs)
return action_utils.run(arguments, env=env, check=check, **kwargs)

View File

@ -1,8 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configuration helper for WireGuard."""
import subprocess
from plinth import action_utils
from plinth.actions import privileged
SERVER_INTERFACE = 'wg0'
@ -11,8 +10,8 @@ SERVER_INTERFACE = 'wg0'
@privileged
def get_info() -> dict[str, dict]:
"""Return info for each configured interface."""
output = subprocess.check_output(['wg', 'show', 'all',
'dump']).decode().strip()
output = action_utils.run(['wg', 'show', 'all', 'dump'],
check=True).stdout.decode().strip()
lines = output.split('\n')
interfaces: dict[str, dict] = {}
for line in lines:

View File

@ -6,7 +6,6 @@ import pathlib
import random
import shutil
import string
import subprocess
import augeas
@ -90,8 +89,8 @@ def _create_database(db_name):
# Wordpress' install.php creates the tables.
# SQL injection is avoided due to known input.
query = f'''CREATE DATABASE {db_name};'''
subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
check=True)
action_utils.run(['mysql', '--user', 'root'], input=query.encode(),
check=True)
def _set_privileges(db_host, db_name, db_user, db_password):
@ -103,8 +102,8 @@ def _set_privileges(db_host, db_name, db_user, db_password):
IDENTIFIED BY '{db_password}';
FLUSH PRIVILEGES;
'''
subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
check=True)
action_utils.run(['mysql', '--user', 'root'], input=query.encode(),
check=True)
def _generate_secret_key(length=64, chars=None):
@ -146,7 +145,7 @@ def dump_database():
_db_backup_file.parent.mkdir(parents=True, exist_ok=True)
with action_utils.service_ensure_running('mysql'):
with _db_backup_file.open('w', encoding='utf-8') as file_handle:
subprocess.run([
action_utils.run([
'mysqldump', '--add-drop-database', '--add-drop-table',
'--add-drop-trigger', '--user', 'root', '--databases', DB_NAME
], stdout=file_handle, check=True)
@ -157,8 +156,8 @@ def restore_database():
"""Restore database from file."""
with action_utils.service_ensure_running('mysql'):
with _db_backup_file.open('r', encoding='utf-8') as file_handle:
subprocess.run(['mysql', '--user', 'root'], stdin=file_handle,
check=True)
action_utils.run(['mysql', '--user', 'root'], stdin=file_handle,
check=True)
_set_privileges(DB_HOST, DB_NAME, DB_USER, _read_db_password())
@ -192,9 +191,9 @@ def _drop_database(db_host, db_name, db_user):
"""Drop the mysql database that was created during install."""
with action_utils.service_ensure_running('mysql'):
query = f"DROP DATABASE {db_name};"
subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
check=False)
action_utils.run(['mysql', '--user', 'root'], input=query.encode(),
check=False)
query = f"DROP USER IF EXISTS {db_user}@{db_host};"
subprocess.run(['mysql', '--user', 'root'], input=query.encode(),
check=False)
action_utils.run(['mysql', '--user', 'root'], input=query.encode(),
check=False)

View File

@ -33,15 +33,13 @@ def get_configuration() -> dict[str, str]:
"""Return the current configuration."""
configuration = {}
try:
process = subprocess.run(['zoph', '--dump-config'],
stdout=subprocess.PIPE, check=True)
process = action_utils.run(['zoph', '--dump-config'], check=True)
except subprocess.CalledProcessError as exception:
if exception.returncode != 96:
raise
_zoph_setup_cli_user()
process = subprocess.run(['zoph', '--dump-config'],
stdout=subprocess.PIPE, check=True)
process = action_utils.run(['zoph', '--dump-config'], check=True)
for line in process.stdout.decode().splitlines():
name, value = line.partition(':')[::2]
@ -75,13 +73,13 @@ WHERE
def _zoph_configure(key, value):
"""Set a configure value in Zoph."""
try:
subprocess.run(['zoph', '--config', key, value], check=True)
action_utils.run(['zoph', '--config', key, value], check=True)
except subprocess.CalledProcessError as exception:
if exception.returncode != 96:
raise
_zoph_setup_cli_user()
subprocess.run(['zoph', '--config', key, value], check=True)
action_utils.run(['zoph', '--config', key, value], check=True)
@privileged
@ -137,17 +135,16 @@ def set_configuration(enable_osm: bool | None = None,
query = f"UPDATE zoph_users SET user_name='{admin_user}' \
WHERE user_name='admin';"
subprocess.run(['mysql', _get_db_config()['db_name']],
input=query.encode(), check=True)
action_utils.run(['mysql', _get_db_config()['db_name']],
input=query.encode(), check=True)
@privileged
def is_configured() -> bool | None:
"""Return whether zoph app is configured."""
try:
process = subprocess.run(
['zoph', '--get-config', 'interface.user.remote'],
stdout=subprocess.PIPE, check=True)
process = action_utils.run(
['zoph', '--get-config', 'interface.user.remote'], check=True)
return process.stdout.decode().strip() == 'true'
except (FileNotFoundError, subprocess.CalledProcessError):
return None
@ -163,8 +160,8 @@ def dump_database():
db_name = _get_db_config()['db_name']
os.makedirs(os.path.dirname(DB_BACKUP_FILE), exist_ok=True)
with open(DB_BACKUP_FILE, 'w', encoding='utf-8') as db_backup_file:
subprocess.run(['mysqldump', db_name], stdout=db_backup_file,
check=True)
action_utils.run(['mysqldump', db_name], stdout=db_backup_file,
check=True)
@privileged
@ -178,15 +175,16 @@ def restore_database():
db_user = _get_db_config()['db_user']
db_host = _get_db_config()['db_host']
db_pass = _get_db_config()['db_pass']
subprocess.run(['mysqladmin', '--force', 'drop', db_name], check=False)
subprocess.run(['mysqladmin', 'create', db_name], check=True)
action_utils.run(['mysqladmin', '--force', 'drop', db_name],
check=False)
action_utils.run(['mysqladmin', 'create', db_name], check=True)
with open(DB_BACKUP_FILE, 'r', encoding='utf-8') as db_restore_file:
subprocess.run(['mysql', db_name], stdin=db_restore_file,
check=True)
action_utils.run(['mysql', db_name], stdin=db_restore_file,
check=True)
# Set the password for user from restored configuration
query = f'ALTER USER {db_user}@{db_host} IDENTIFIED BY "{db_pass}";'
subprocess.run(['mysql'], input=query.encode(), check=True)
action_utils.run(['mysql'], input=query.encode(), check=True)
@privileged
@ -198,12 +196,12 @@ def uninstall():
with action_utils.service_ensure_running('mysql'):
try:
config = _get_db_config()
subprocess.run(
action_utils.run(
['mysqladmin', '--force', 'drop', config['db_name']],
check=False)
query = f'DROP USER IF EXISTS {config["db_user"]}@localhost;'
subprocess.run(['mysql'], input=query.encode(), check=False)
action_utils.run(['mysql'], input=query.encode(), check=False)
except FileNotFoundError: # Database configuration not found
pass

View File

@ -3,7 +3,6 @@
import logging
import os
import subprocess
from collections import defaultdict
from typing import Any
@ -14,7 +13,7 @@ import apt_pkg
from plinth import action_utils
from plinth import app as app_module
from plinth import module_loader
from plinth.action_utils import run_apt_command
from plinth.action_utils import run, run_apt_command
from plinth.actions import privileged
logger = logging.getLogger(__name__)
@ -61,7 +60,7 @@ def install(app_id: str, packages: list[str], skip_recommends: bool = False,
if force_missing_configuration:
extra_arguments += ['-o', 'Dpkg::Options::=--force-confmiss']
subprocess.run(['dpkg', '--configure', '-a'], check=False)
run(['dpkg', '--configure', '-a'], check=False)
with action_utils.apt_hold_freedombox():
run_apt_command(['--fix-broken', 'install'])
returncode = run_apt_command(['install'] + extra_arguments + packages)
@ -79,7 +78,7 @@ def remove(app_id: str, packages: list[str], purge: bool):
except Exception:
raise PermissionError(f'Packages are not managed: {packages}')
subprocess.run(['dpkg', '--configure', '-a'], check=False)
run(['dpkg', '--configure', '-a'], check=False)
with action_utils.apt_hold_freedombox():
run_apt_command(['--fix-broken', 'install'])
options = [] if not purge else ['--purge']

View File

@ -8,10 +8,12 @@ import logging
import os
import pathlib
import pwd
import signal
import socket
import socketserver
import struct
import sys
import threading
import time
import systemd.daemon
@ -28,6 +30,8 @@ FREEDOMBOX_PROCESS_USER = 'plinth'
MAX_REQUEST_LENGTH = 1_000_000
_server = None
idle_shutdown_time: int | None = 5 * 60 # 5 minutes
freedombox_develop = False
@ -207,7 +211,7 @@ class Server(socketserver.ThreadingUnixStreamServer):
def client_main() -> None:
"""Parse arguments for the client for privileged daemon."""
log.action_init()
log.action_init(console=True)
parser = argparse.ArgumentParser()
parser.add_argument('module', help='Module to trigger action in')
@ -245,6 +249,25 @@ def client_main() -> None:
sys.exit(1)
def _on_sigterm(signal_number: int, frame) -> None:
"""Handle SIGTERM signal. Issue server shutdown."""
threading.Thread(target=_shutdown_server).start()
def _shutdown_server() -> None:
"""Issue a shutdown request to the server.
This must be run in a thread separate from the server.serve_forever()
otherwise it will deadlock waiting for the shutdown to complete.
"""
global _server
logger.info('SIGTERM received, shutting down the server.')
if _server:
_server.shutdown()
logger.info('Shutdown complete, some requests may be running.')
def main() -> None:
"""Start the server, listen on socket, and serve forever."""
global freedombox_develop, idle_shutdown_time
@ -263,10 +286,15 @@ def main() -> None:
if not systemd.daemon.listen_fds(unset_environment=False):
idle_shutdown_time = None
signal.signal(signal.SIGTERM, _on_sigterm)
module_loader.load_modules()
app_module.apps_init()
with Server(str(address), RequestHandler) as server:
global _server
_server = server # Reference needed to shutdown the server.
# systemd will wait until notification to proceed with other processes.
# We have service Type=notify.
systemd.daemon.notify('READY=1')
@ -283,6 +311,11 @@ def main() -> None:
else:
logger.info('FreedomBox privileged daemon exiting.')
# Exit the context manager. This calls server.close() which waits on
# all pending request threads to complete.
logger.info('All requested completed. Exit.')
if __name__ == '__main__':
main()

View File

@ -350,7 +350,7 @@ def run_setup_on_apps(app_ids, allow_install=True):
else:
setup_apps(app_ids, allow_install=allow_install)
except Exception as exception:
logger.error('Error running setup - %s', exception)
logger.exception('Error running setup - %s', exception)
raise

View File

@ -81,54 +81,55 @@ def test_is_enabled(service_is_enabled, daemon):
@patch('subprocess.run')
def test_enable(subprocess_run, apps_init, app_list, mock_privileged, daemon):
"""Test that enabling the daemon works."""
common_args = dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False)
daemon.enable()
subprocess_run.assert_has_calls(
[call(['systemctl', 'enable', 'test-unit'], check=False)])
[call(['systemctl', 'enable', 'test-unit'], **common_args)])
subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
subprocess_run.reset_mock()
daemon.alias = 'test-unit-2'
daemon.enable()
subprocess_run.assert_has_calls([
call(['systemctl', 'enable', 'test-unit'], check=False),
call(['systemctl', 'start', 'test-unit'], stdout=subprocess.DEVNULL,
check=False),
call(['systemctl', 'enable', 'test-unit-2'], check=False),
call(['systemctl', 'start', 'test-unit-2'], stdout=subprocess.DEVNULL,
check=False),
call(['systemctl', 'enable', 'test-unit'], **common_args),
call(['systemctl', 'start', 'test-unit'], **common_args),
call(['systemctl', 'enable', 'test-unit-2'], **common_args),
call(['systemctl', 'start', 'test-unit-2'], **common_args),
])
subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
subprocess_run.assert_any_call(['systemctl', 'start', 'test-unit-2'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
@patch('plinth.app.apps_init')
@patch('subprocess.run')
def test_disable(subprocess_run, apps_init, app_list, mock_privileged, daemon):
"""Test that disabling the daemon works."""
common_args = dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False)
daemon.disable()
subprocess_run.assert_has_calls(
[call(['systemctl', 'disable', 'test-unit'], check=False)])
[call(['systemctl', 'disable', 'test-unit'], **common_args)])
subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
subprocess_run.reset_mock()
daemon.alias = 'test-unit-2'
daemon.disable()
subprocess_run.assert_has_calls([
call(['systemctl', 'disable', 'test-unit'], check=False),
call(['systemctl', 'stop', 'test-unit'], stdout=subprocess.DEVNULL,
check=False),
call(['systemctl', 'disable', 'test-unit-2'], check=False),
call(['systemctl', 'stop', 'test-unit-2'], stdout=subprocess.DEVNULL,
check=False),
call(['systemctl', 'disable', 'test-unit'], **common_args),
call(['systemctl', 'stop', 'test-unit'], **common_args),
call(['systemctl', 'disable', 'test-unit-2'], **common_args),
call(['systemctl', 'stop', 'test-unit-2'], **common_args),
])
subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
subprocess_run.assert_any_call(['systemctl', 'stop', 'test-unit-2'],
stdout=subprocess.DEVNULL, check=False)
**common_args)
@patch('plinth.action_utils.service_is_running')
@ -148,6 +149,8 @@ def test_is_running(service_is_running, daemon):
def test_ensure_running(subprocess_run, service_is_running, apps_init,
app_list, mock_privileged, daemon):
"""Test that checking that the daemon is running works."""
common_args = dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False)
service_is_running.return_value = True
with daemon.ensure_running() as starting_state:
assert starting_state
@ -159,16 +162,14 @@ def test_ensure_running(subprocess_run, service_is_running, apps_init,
with daemon.ensure_running() as starting_state:
assert not starting_state
assert subprocess_run.mock_calls == [
call(['systemctl', 'enable', 'test-unit'], check=False),
call(['systemctl', 'start', 'test-unit'],
stdout=subprocess.DEVNULL, check=False),
call(['systemctl', 'enable', 'test-unit'], **common_args),
call(['systemctl', 'start', 'test-unit'], **common_args),
]
subprocess_run.reset_mock()
assert subprocess_run.mock_calls == [
call(['systemctl', 'disable', 'test-unit'], check=False),
call(['systemctl', 'stop', 'test-unit'], stdout=subprocess.DEVNULL,
check=False),
call(['systemctl', 'disable', 'test-unit'], **common_args),
call(['systemctl', 'stop', 'test-unit'], **common_args),
]