mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +00:00
Compare commits
33 Commits
84a79d923d
...
4ed2a25a8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ed2a25a8b | ||
|
|
dc837bd6b8 | ||
|
|
e38ac648fe | ||
|
|
1c0f25c134 | ||
|
|
b559e1998a | ||
|
|
f2bceb48cf | ||
|
|
397a67329b | ||
|
|
279738c305 | ||
|
|
f19ab68553 | ||
|
|
7f608cd570 | ||
|
|
b253166f6d | ||
|
|
bf9005ac48 | ||
|
|
80e6d940a4 | ||
|
|
61ff15a04f | ||
|
|
355812c9f2 | ||
|
|
f559870d3e | ||
|
|
2fbaea191f | ||
|
|
a43082308d | ||
|
|
5566f05cad | ||
|
|
647e72516c | ||
|
|
daca4d1d9c | ||
|
|
1ad48ecad8 | ||
|
|
0fdf59b9f0 | ||
|
|
636b4cabd8 | ||
|
|
288b58e0b5 | ||
|
|
7c0fa00536 | ||
|
|
c2d5d1d3c8 | ||
|
|
c8f89e3ca5 | ||
|
|
e227e9a919 | ||
|
|
904e5935cb | ||
|
|
d512a8b645 | ||
|
|
996596ddc0 | ||
|
|
9c3776b03d |
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,7 +4,7 @@
|
||||
doc/manual/*/*.pdf
|
||||
doc/manual/*/*.html
|
||||
doc/manual/*/*.xml
|
||||
doc/plinth.1
|
||||
doc/*.1
|
||||
doc/dev/_build
|
||||
\#*
|
||||
.#*
|
||||
|
||||
@ -56,7 +56,7 @@ build:
|
||||
build-backports:
|
||||
extends: .build-package
|
||||
variables:
|
||||
RELEASE: bookworm-backports
|
||||
RELEASE: trixie-backports
|
||||
|
||||
build i386:
|
||||
extends: .build-package-i386
|
||||
|
||||
3
Makefile
3
Makefile
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
5
debian/freedombox.lintian-overrides
vendored
5
debian/freedombox.lintian-overrides
vendored
@ -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]
|
||||
|
||||
1
debian/freedombox.manpages
vendored
1
debian/freedombox.manpages
vendored
@ -1 +1,2 @@
|
||||
./doc/freedombox-cmd.1
|
||||
./doc/plinth.1
|
||||
|
||||
10
debian/rules
vendored
10
debian/rules
vendored
@ -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
|
||||
|
||||
@ -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
160
doc/freedombox-cmd.xml
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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__()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user