freedombox Debian release 22.22

-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCgAdFiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmNE1JUACgkQd8DHXntl
 CAifSQ/+OYYsEqoz1Q7ClpIIviTxBUsQna9d9kArCkqMmSfzRNEVI9dfOVxUAhTH
 XVkJoxS/zmyDiCWtFMxHx0HX6C0XgFmG/scznxZHUjwVRxVdaNaFYpSgfB9YnvnX
 9ppHRNw7fpz0MvGEMYjpdSjY8RW4/8bFsKSZJMjn4zBKg1OcBd+7rSmnaW3SAreh
 9P4wkNnToj9blMq+5iIJWuemWSB+aWPbLpPzb9c24TLJaehvobR0VS2cagZFrYwF
 NLOxipk0JzrBUrFcv/ytxXK5NbsPyVAZB1wcW9jM5nS+70jd9gIXJzDuiJ1bpB34
 E1aBc1nlFWN3GtDKRVJolwC2uplu8+1p35tm5gib0jziqzwo95ZqRSSPhLioniaD
 Zsxae4nxzaej89eiQUOiy8MtOC+N/MmrTcOS9vGcS0l2Am92P8LQ3Q7Fr7x65gI8
 ZPkWPkP2kZL7BJdLTP33Dsr5lnqPhHXrBqbGGhxRUA3CYdLijabBjK3GgmRcTiz1
 jomJ3+cfT7yEvk3bVMv9mguVee18GfGvThIsPdJq3p9ihEhrYcwUL7W2BAtC8zuE
 sfysIBR+dNmQHtST2OfuAdGMv4T3u+Pq1lO9ADhESsuwip9+DXS8dWn3EiCSAxg+
 Dqx7cujvoJ2nAwMG0wdsh25Ze/CKAkBoDBAONJkGOKAmMAeDEpU=
 =wEbG
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCgAdFiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmNJaGMACgkQd8DHXntl
 CAjx+A/8CxwL12NhHPrWJVKQm8vVUMUVzHowenDTUs7CytqMDaqsuCjyGnNQwC87
 A21OnLTWV2UIwoX/pEuHcVzW2ZJIxj0sgfuP3l2jibvZrfscfKPWJCObvGFx6Xp/
 JLfd3pX5vxGhRkBXjxz4DSzoiFgfO7hKg2h/q1A7xhSRTtjSuDIH3RPsRtZcvlIF
 k1FwmGdOFee1WQ5qhlAxIPLNVW3a71PFXErcpbxA1Gtm3HSYJQsnAKD8LUL8jYiU
 hwfNrn6Qd65yNxVUsgmUJlUOmoZCLDRUWhphqU+qs2l7Ddk1ChnL4S8uEx/AVqUU
 JqmsOM0QDDCkJXAnpvxfCtGik/x19/TLktXMNmoESYLmDMGOEjJWLWjAqGjfb8+J
 iiFExuXNN90ZJsuA9gJlX5JOA1fDTOBZshyqkmqNi3kFX2fDAVJ1sd/wqBWld/4w
 kVKQBL0JjKNSWGudpPJbZK1OrFAE94qBJGqWcbXRgIZjlsrisWP/hjOJOI9forsY
 LQlTuaw/yMr74vMa80ggFoqAv8JiEiGtUhZPBfzWgGTF59vJ2w7T9eut0CPll5/f
 4dcPmMhms+udfxH/EMKSBQSEldh+wrX8jD6fghnOj10YXuh/AReT/fsbIbpvQQiz
 xXykNJ5nL7+nmkh7teMtZ+RkSKCU+9VmF2MuUnV9DD0QMbSvddc=
 =Nx9W
 -----END PGP SIGNATURE-----

Merge tag 'v22.22' into debian/bullseye-backports

freedombox Debian release 22.22

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2022-10-14 09:47:14 -04:00
commit df2fb42536
306 changed files with 29005 additions and 32099 deletions

View File

@ -16,7 +16,7 @@ code-quality:
stage: test stage: test
needs: [] needs: []
script: script:
- python3 -m flake8 --exclude actions/domainname-change,actions/dynamicdns,actions/hostname-change,actions/networks container plinth actions/* - python3 -m flake8 container plinth actions/*
unit-tests: unit-tests:
stage: test stage: test

2
Vagrantfile vendored
View File

@ -16,7 +16,7 @@ Vagrant.configure(2) do |config|
end end
config.vm.provision "shell", run: 'always', inline: <<-SHELL config.vm.provision "shell", run: 'always', inline: <<-SHELL
# Disable automatic upgrades # Disable automatic upgrades
/vagrant/actions/upgrades disable-auto echo -e 'APT::Periodic::Update-Package-Lists "0";\nAPT::Periodic::Unattended-Upgrade "0";' > //etc/apt/apt.conf.d/20auto-upgrades
# Do not run system plinth # Do not run system plinth
systemctl stop plinth systemctl stop plinth
systemctl disable plinth systemctl disable plinth

View File

@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import sys import sys
import traceback
import typing import typing
import plinth.log import plinth.log
@ -26,16 +27,25 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('module', help='Module to trigger action in') parser.add_argument('module', help='Module to trigger action in')
parser.add_argument('action', help='Action to trigger in module') parser.add_argument('action', help='Action to trigger in module')
parser.add_argument('--write-fd', type=int, default=1,
help='File descriptor to write output to')
parser.add_argument('--no-args', default=False, action='store_true',
help='Do not read arguments from stdin')
args = parser.parse_args() args = parser.parse_args()
try: try:
try: try:
arguments = json.loads(sys.stdin.read()) arguments = {'args': [], 'kwargs': {}}
if not args.no_args:
input_ = sys.stdin.read()
if input_:
arguments = json.loads(input_)
except json.JSONDecodeError as exception: except json.JSONDecodeError as exception:
raise SyntaxError('Arguments on stdin not JSON.') from exception raise SyntaxError('Arguments on stdin not JSON.') from exception
return_value = _call(args.module, args.action, arguments) return_value = _call(args.module, args.action, arguments)
print(json.dumps(return_value)) with os.fdopen(args.write_fd, 'w') as write_file_handle:
write_file_handle.write(json.dumps(return_value))
except PermissionError as exception: except PermissionError as exception:
logger.error(exception.args[0]) logger.error(exception.args[0])
sys.exit(EXIT_PERM) sys.exit(EXIT_PERM)
@ -52,14 +62,15 @@ def main():
def _call(module_name, action_name, arguments): def _call(module_name, action_name, arguments):
"""Import the module and run action as superuser""" """Import the module and run action as superuser"""
if os.getuid() != 0:
raise PermissionError('This action is reserved for root')
if '.' in module_name: if '.' in module_name:
raise SyntaxError('Invalid module name') raise SyntaxError('Invalid module name')
cfg.read() cfg.read()
if module_name == 'plinth':
import_path = 'plinth'
else:
import_path = module_loader.get_module_import_path(module_name) import_path = module_loader.get_module_import_path(module_name)
try: try:
module = importlib.import_module(import_path + '.privileged') module = importlib.import_module(import_path + '.privileged')
except ModuleNotFoundError as exception: except ModuleNotFoundError as exception:
@ -87,7 +98,8 @@ def _call(module_name, action_name, arguments):
'exception': { 'exception': {
'module': type(exception).__module__, 'module': type(exception).__module__,
'name': type(exception).__name__, 'name': type(exception).__name__,
'args': exception.args 'args': exception.args,
'traceback': traceback.format_tb(exception.__traceback__)
} }
} }

View File

@ -1,349 +0,0 @@
#!/usr/bin/python3
# -*- mode: python -*-
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Wrapper to handle backups using borg-backups.
"""
import argparse
import json
import os
import pathlib
import re
import subprocess
import sys
import tarfile
from plinth.modules.backups import MANIFESTS_FOLDER
from plinth.utils import Version
TIMEOUT = 30
BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/')
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
setup = subparsers.add_parser(
'setup', help='Create repository if it does not already exist')
init = subparsers.add_parser('init', help='Initialize a repository')
init.add_argument('--encryption', help='Encryption of the repository',
required=True)
info = subparsers.add_parser('info', help='Show repository information')
list_repo = subparsers.add_parser('list-repo',
help='List repository contents')
create_archive = subparsers.add_parser('create-archive',
help='Create archive')
create_archive.add_argument('--paths', help='Paths to include in archive',
nargs='+')
create_archive.add_argument('--comment',
help='Comment text to add to archive',
default='')
delete_archive = subparsers.add_parser('delete-archive',
help='Delete archive')
export_help = 'Export archive contents as tar on stdout'
export_tar = subparsers.add_parser('export-tar', help=export_help)
get_archive_apps = subparsers.add_parser(
'get-archive-apps', help='Get list of apps included in archive')
restore_archive = subparsers.add_parser(
'restore-archive', help='Restore files from an archive')
restore_archive.add_argument('--destination', help='Destination',
required=True)
for cmd in [
info, init, list_repo, create_archive, delete_archive, export_tar,
get_archive_apps, restore_archive, setup
]:
cmd.add_argument('--path', help='Repository or Archive path',
required=False)
cmd.add_argument('--ssh-keyfile', help='Path of private ssh key',
default=None)
get_exported_archive_apps = subparsers.add_parser(
'get-exported-archive-apps',
help='Get list of apps included in exported archive file')
get_exported_archive_apps.add_argument('--path', help='Tarball file path',
required=True)
restore_exported_archive = subparsers.add_parser(
'restore-exported-archive',
help='Restore files from an exported archive')
restore_exported_archive.add_argument('--path', help='Tarball file path',
required=True)
dump_settings = subparsers.add_parser('dump-settings',
help='Dump JSON settings to a file')
dump_settings.add_argument('--app-id',
help='ID of the app to dump settings for')
load_settings = subparsers.add_parser(
'load-settings', help='Load JSON settings from a file')
load_settings.add_argument('--app-id',
help='ID of the app to load settings for')
subparsers.required = True
return parser.parse_args()
def subcommand_setup(arguments):
"""Create repository if it does not already exist."""
try:
run(['borg', 'info', arguments.path], arguments, check=True)
except subprocess.CalledProcessError:
path = os.path.dirname(arguments.path)
if not os.path.exists(path):
os.makedirs(path)
init_repository(arguments, encryption='none')
def init_repository(arguments, encryption):
"""Initialize a local or remote borg repository"""
if encryption != 'none':
if not _read_encryption_passphrase(arguments):
raise ValueError('No encryption passphrase provided')
cmd = ['borg', 'init', '--encryption', encryption, arguments.path]
run(cmd, arguments)
def subcommand_init(arguments):
"""Initialize the borg repository."""
init_repository(arguments, encryption=arguments.encryption)
def subcommand_info(arguments):
"""Show repository information."""
run(['borg', 'info', '--json', arguments.path], arguments)
def subcommand_list_repo(arguments):
"""List repository contents."""
run(['borg', 'list', '--json', '--format="{comment}"', arguments.path],
arguments)
def _get_borg_version(arugments):
"""Return the version of borgbackup."""
process = run(['borg', '--version'], arugments, stdout=subprocess.PIPE)
return process.stdout.decode().split()[1] # Example: "borg 1.1.9"
def subcommand_create_archive(arguments):
"""Create archive."""
paths = filter(os.path.exists, arguments.paths)
command = ['borg', 'create', '--json']
if arguments.comment:
comment = arguments.comment
if Version(_get_borg_version(arguments)) < Version('1.1.10'):
# Undo any placeholder escape sequences in comments as this version
# of borg does not support placeholders. XXX: Drop this code when
# support for borg < 1.1.10 is dropped.
comment = comment.replace('{{', '{').replace('}}', '}')
command += ['--comment', comment]
command += [arguments.path] + list(paths)
run(command, arguments)
def subcommand_delete_archive(arguments):
"""Delete archive."""
run(['borg', 'delete', arguments.path], arguments)
def _extract(archive_path, destination, arguments, locations=None):
"""Extract archive contents."""
prev_dir = os.getcwd()
borg_call = ['borg', 'extract', archive_path]
# do not extract any files when we get an empty locations list
if locations is not None:
borg_call.extend(locations)
try:
os.chdir(os.path.expanduser(destination))
# TODO: with python 3.7 use subprocess.run with the 'capture_output'
# argument
process = run(borg_call, arguments, check=False,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if process.returncode != 0:
error = process.stderr.decode()
# Don't fail on the borg error when no files were matched
if "never matched" not in error:
raise subprocess.CalledProcessError(process.returncode,
process.args)
finally:
os.chdir(prev_dir)
def subcommand_export_tar(arguments):
"""Export archive contents as tar stream on stdout."""
run(['borg', 'export-tar', arguments.path, '-', '--tar-filter=gzip'],
arguments)
def _read_archive_file(archive, filepath, arguments):
"""Read the content of a file inside an archive"""
borg_call = ['borg', 'extract', archive, filepath, '--stdout']
return run(borg_call, arguments, stdout=subprocess.PIPE).stdout.decode()
def subcommand_get_archive_apps(arguments):
"""Get list of apps included in archive."""
manifest_folder = os.path.relpath(MANIFESTS_FOLDER, '/')
borg_call = [
'borg', 'list', arguments.path, manifest_folder, '--format',
'{path}{NEWLINE}'
]
try:
borg_process = run(borg_call, arguments, stdout=subprocess.PIPE)
manifest_path = borg_process.stdout.decode().strip()
except subprocess.CalledProcessError:
sys.exit(1)
manifest = None
if manifest_path:
manifest_data = _read_archive_file(arguments.path, manifest_path,
arguments)
manifest = json.loads(manifest_data)
if manifest:
for app in _get_apps_of_manifest(manifest):
print(app['name'])
def _get_apps_of_manifest(manifest):
"""Get apps of a manifest.
Supports both dict format as well as list format of plinth <=0.42
"""
if isinstance(manifest, list):
apps = manifest
elif isinstance(manifest, dict) and 'apps' in manifest:
apps = manifest['apps']
else:
raise RuntimeError('Unknown manifest format')
return apps
def subcommand_get_exported_archive_apps(arguments):
"""Get list of apps included in an exported archive file."""
manifest = None
with tarfile.open(arguments.path) as tar_handle:
filenames = tar_handle.getnames()
for name in filenames:
if 'var/lib/plinth/backups-manifests/' in name \
and name.endswith('.json'):
manifest_data = tar_handle.extractfile(name).read()
manifest = json.loads(manifest_data)
break
if manifest:
for app in _get_apps_of_manifest(manifest):
print(app['name'])
def subcommand_restore_archive(arguments):
"""Restore files from an archive."""
_locations = json.loads(arguments.stdin)
locations = _locations['directories'] + _locations['files']
locations = [os.path.relpath(location, '/') for location in locations]
_extract(arguments.path, arguments.destination, arguments,
locations=locations)
def subcommand_restore_exported_archive(arguments):
"""Restore files from an exported archive."""
locations = json.loads(arguments.stdin)
with tarfile.open(arguments.path) as tar_handle:
for member in tar_handle.getmembers():
path = '/' + member.name
if path in locations['files']:
tar_handle.extract(member, '/')
else:
for directory in locations['directories']:
if path.startswith(directory):
tar_handle.extract(member, '/')
break
def _assert_app_id(app_id):
"""Check that app ID is correct."""
if not re.fullmatch(r'[a-z0-9_]+', app_id):
raise Exception('Invalid App ID')
def subcommand_dump_settings(arguments):
"""Dump an app's settings to a JSON file."""
app_id = arguments.app_id
_assert_app_id(app_id)
BACKUPS_DATA_PATH.mkdir(exist_ok=True)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
settings_path.write_text(arguments.stdin)
def subcommand_load_settings(arguments):
"""Load an app's settings from a JSON file."""
app_id = arguments.app_id
_assert_app_id(app_id)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
try:
print(settings_path.read_text())
except FileNotFoundError:
print('{}')
def _read_encryption_passphrase(arguments):
"""Read encryption passphrase from stdin."""
if arguments.stdin:
try:
return json.loads(arguments.stdin)['encryption_passphrase']
except KeyError:
pass
return None
def get_env(arguments):
"""Create encryption and ssh kwargs out of given arguments"""
env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes',
LANG='C.UTF-8')
# Always provide BORG_PASSPHRASE (also if empty) so borg does not get stuck
# while asking for a passphrase.
encryption_passphrase = _read_encryption_passphrase(arguments)
env['BORG_PASSPHRASE'] = encryption_passphrase or ''
return env
def run(cmd, arguments, check=True, **kwargs):
"""Wrap the command with extra encryption passphrase handling."""
env = get_env(arguments)
return subprocess.run(cmd, check=check, env=env, **kwargs)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
arguments.stdin = sys.stdin.read()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,62 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for BIND server.
"""
import argparse
from pathlib import Path
from plinth import action_utils
from plinth.modules.bind import (CONFIG_FILE, DEFAULT_CONFIG, ZONES_DIR,
set_dnssec, set_forwarders)
def parse_arguments():
"""Return parsed command line arguments as dictionary"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
setup = subparsers.add_parser('setup', help='Setup for BIND')
setup.add_argument(
'--old-version', type=int, required=True,
help='Earlier version of the app that is already setup.')
configure = subparsers.add_parser('configure', help='Configure BIND')
configure.add_argument('--forwarders',
help='List of IP addresses, separated by space')
configure.add_argument('--dnssec', choices=['enable', 'disable'],
help='Enable or disable DNSSEC')
subparsers.required = True
return parser.parse_args()
def subcommand_setup(arguments):
"""Setup BIND configuration."""
if arguments.old_version == 0:
with open(CONFIG_FILE, 'w', encoding='utf-8') as conf_file:
conf_file.write(DEFAULT_CONFIG)
Path(ZONES_DIR).mkdir(exist_ok=True, parents=True)
action_utils.service_restart('named')
def subcommand_configure(arguments):
"""Configure BIND."""
set_forwarders(arguments.forwarders)
set_dnssec(arguments.dnssec)
action_utils.service_restart('named')
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,76 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for calibre.
"""
import argparse
import json
import pathlib
import shutil
import subprocess
from plinth.modules import calibre
LIBRARIES_PATH = pathlib.Path('/var/lib/calibre-server-freedombox/libraries')
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('list-libraries',
help='Return the list of libraries setup')
subparser = subparsers.add_parser('create-library',
help='Create an empty library')
subparser.add_argument('name', help='Name of the new library')
subparser = subparsers.add_parser('delete-library',
help='Delete a library and its contents')
subparser.add_argument('name', help='Name of the library to delete')
subparsers.required = True
return parser.parse_args()
def subcommand_list_libraries(_):
"""Return the list of libraries setup."""
libraries = []
for library in LIBRARIES_PATH.glob('*/metadata.db'):
libraries.append(str(library.parent.name))
print(json.dumps({'libraries': libraries}))
def subcommand_create_library(arguments):
"""Create an empty library."""
calibre.validate_library_name(arguments.name)
library = LIBRARIES_PATH / arguments.name
library.mkdir(mode=0o755) # Raise exception if already exists
subprocess.call(
['calibredb', '--with-library', library, 'list_categories'],
stdout=subprocess.DEVNULL)
# Force systemd StateDirectory= logic to assign proper ownership to the
# DynamicUser=
shutil.chown(LIBRARIES_PATH.parent, 'root', 'root')
def subcommand_delete_library(arguments):
"""Delete a library and its contents."""
calibre.validate_library_name(arguments.name)
library = LIBRARIES_PATH / arguments.name
shutil.rmtree(library)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,80 +0,0 @@
#!/usr/bin/python3
# -*- mode: python -*-
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for FreedomBox general configuration.
"""
import argparse
import os
import augeas
from plinth import action_utils
from plinth.modules.config import (APACHE_HOMEPAGE_CONF_FILE_NAME,
FREEDOMBOX_APACHE_CONFIG)
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
set_home_page = subparsers.add_parser(
'set-home-page',
help='Set the home page for this FreedomBox instance.')
set_home_page.add_argument('homepage',
help='path to the webserver home page')
subparsers.add_parser('reset-home-page',
help='Reset the homepage of the Apache server.')
subparsers.required = True
return parser.parse_args()
def subcommand_set_home_page(arguments):
"""Set the default app for this FreedomBox."""
conf_file_path = os.path.join('/etc/apache2/conf-available',
APACHE_HOMEPAGE_CONF_FILE_NAME)
redirect_rule = 'RedirectMatch "^/$" "{}"\n'.format(arguments.homepage)
with open(conf_file_path, 'w', encoding='utf-8') as conf_file:
conf_file.write(redirect_rule)
action_utils.webserver_enable('freedombox-apache-homepage')
def subcommand_reset_home_page(_):
"""Sets the Apache web server's home page to the default - /plinth."""
config_file = FREEDOMBOX_APACHE_CONFIG
default_path = 'plinth'
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/Httpd/lens', 'Httpd.lns')
aug.set('/augeas/load/Httpd/incl[last() + 1]', config_file)
aug.load()
aug.defvar('conf', '/files' + config_file)
for match in aug.match('/files' + config_file +
'/directive["RedirectMatch"]'):
if aug.get(match + "/arg[1]") == '''"^/$"''':
aug.set(match + "/arg[2]", '"/{}"'.format(default_path))
aug.save()
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,21 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
domainname="$1"
hostname=$(hostname)
if [ -z "$domainname" ] ; then
if grep -q 127.0.1.1 /etc/hosts ; then
sed -i "s/127.0.1.1.*/127.0.1.1 $hostname/" /etc/hosts
else
sed -i "/127.0.0.1.*/a \
127.0.1.1 $hostname" /etc/hosts
fi
else
if grep -q 127.0.1.1 /etc/hosts ; then
sed -i "s/127.0.1.1.*/127.0.1.1 $hostname.$domainname $hostname/" /etc/hosts
else
sed -i "/127.0.0.1.*/a \
127.0.1.1 $hostname.$domainname $hostname" /etc/hosts
fi
fi

View File

@ -1,159 +1,9 @@
#!/usr/bin/python3 #!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Legacy configuration helper for Dynamic DNS, kept for compatibility.
Cron jobs in the earlier implementation used to call into this script with the
sub-commands 'update' and 'success'. This action script now allows for any
arbitrary sub-command to be called and does nothing. It can be removed after
the release of Debian 12 (bookworm).
""" """
Configuration helper for Dynamic DNS.
"""
import argparse
import json
import pathlib
import urllib
_conf_dir = pathlib.Path('/etc/ez-ipupdate/')
_active_config = _conf_dir / 'ez-ipupdate.conf'
_inactive_config = _conf_dir / 'ez-ipupdate.inactive'
_helper_config = _conf_dir / 'ez-ipupdate-plinth.cfg'
_cron_job = pathlib.Path('/etc/cron.d/ez-ipupdate')
def parse_arguments():
""" Return parsed command line arguments as dictionary. """
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('export-config',
help='Print configuration in JSON format')
subparsers.add_parser('clean', help='Remove all old configuration files')
subparsers.add_parser('update', help='For backwards compatibility')
subparser = subparsers.add_parser('success',
help='For backwards compatibility')
subparser.add_argument('wan_ip_address')
subparsers.required = True
return parser.parse_args()
def _read_configuration(path, separator='='):
"""Read ez-ipupdate configuration."""
config = {}
for line in path.read_text().splitlines():
if line.startswith('#'):
continue
parts = line.partition(separator)
if parts[1]:
config[parts[0].strip()] = parts[2].strip()
else:
config[parts[0].strip()] = True
return config
def subcommand_export_config(_):
"""Print the old ez-ipupdate configuration in JSON format."""
input_config = {}
if _active_config.exists():
input_config = _read_configuration(_active_config)
elif _inactive_config.exists():
input_config = _read_configuration(_inactive_config)
helper = {}
if _helper_config.exists():
helper.update(_read_configuration(_helper_config, separator=' '))
def _clean(value):
value_map = {'enabled': True, 'disabled': False, '': None}
return value_map.get(value, value)
domain = {
'service_type': 'gnudip',
'domain': input_config.get('host'),
'server': input_config.get('server'),
'username': input_config.get('user', '').split(':')[0] or None,
'password': input_config.get('user', '').split(':')[-1] or None,
'ip_lookup_url': helper.get('IPURL'),
'update_url': _clean(helper.get('POSTURL')) or None,
'use_http_basic_auth': _clean(helper.get('POSTAUTH')),
'disable_ssl_cert_check': _clean(helper.get('POSTSSLIGNORE')),
'use_ipv6': _clean(helper.get('POSTUSEIPV6')),
}
if isinstance(domain['update_url'], bool):
# 'POSTURL ' is a line found in the configuration file
domain['update_url'] = None
if not domain['server']:
domain['service_type'] = 'other'
update_url = domain['update_url']
try:
server = urllib.parse.urlparse(update_url).netloc
service_types = {
'dynupdate.noip.com': 'noip.com',
'dynupdate.no-ip.com': 'noip.com',
'freedns.afraid.org': 'freedns.afraid.org'
}
domain['service_type'] = service_types.get(server, 'other')
except ValueError:
pass
# Old logic for 'enabling' the app is as follows: If behind NAT, add
# cronjob. If not behind NAT and type is update URL, add cronjob. If not
# behind NAT and type is GnuDIP, move inactive configuration to active
# configuration and start the ez-ipupdate daemon.
enabled = False
if _cron_job.exists() or (domain['service_type'] == 'gnudip'
and _active_config.exists()):
enabled = True
output_config = {'enabled': enabled, 'domains': {}}
if domain['domain']:
output_config['domains'][domain['domain']] = domain
print(json.dumps(output_config))
def subcommand_clean(_):
"""Remove all old configuration files."""
last_update = _conf_dir / 'last-update'
status = _conf_dir / 'ez-ipupdate.status'
current_ip = _conf_dir / 'ez-ipupdate.currentIP'
cleanup_files = [
_active_config, _inactive_config, last_update, _helper_config, status,
current_ip
]
for cleanup_file in cleanup_files:
try:
cleanup_file.rename(cleanup_file.with_suffix('.bak'))
except FileNotFoundError:
pass
_cron_job.unlink(missing_ok=True)
def subcommand_update(_):
"""Empty subcommand kept only for backwards compatibility.
Drop after stable release.
"""
def subcommand_success(_):
"""Empty subcommand kept only for backwards compatibility.
Drop after stable release.
"""
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,66 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for email server.
"""
import argparse
import logging
import os
import sys
import plinth.log
from plinth.modules.email import privileged
EXIT_SYNTAX = 10
EXIT_PERM = 20
logger = logging.getLogger(__file__)
def main():
"""Parse arguments."""
plinth.log.action_init()
parser = argparse.ArgumentParser()
parser.add_argument('module', help='Module to trigger action in')
parser.add_argument('action', help='Action to trigger in module')
parser.add_argument('arguments', help='String arguments for action',
nargs='*')
args = parser.parse_args()
try:
_call(args.module, args.action, args.arguments)
except Exception as exception:
logger.exception(exception)
sys.exit(1)
def _call(module_name, action_name, arguments):
"""Import the module and run action as superuser."""
if os.getuid() != 0:
logger.critical('This action is reserved for root')
sys.exit(EXIT_PERM)
# We only run actions defined in the privileged module
if module_name not in privileged.__all__:
logger.critical('Bad module name: %r', module_name)
sys.exit(EXIT_SYNTAX)
module = getattr(privileged, module_name)
try:
action = getattr(module, 'action_' + action_name)
except AttributeError:
logger.critical('Bad action: %s/%r', module_name, action_name)
sys.exit(EXIT_SYNTAX)
for argument in arguments:
if not isinstance(argument, str):
logger.critical('Bad argument: %s', argument)
sys.exit(EXIT_SYNTAX)
action(*arguments)
if __name__ == '__main__':
main()

View File

@ -1,38 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Actions for help module.
"""
import argparse
import subprocess
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('get-logs', help='Get latest FreedomBox logs')
subparsers.required = True
return parser.parse_args()
def subcommand_get_logs(_):
"""Get latest FreedomBox logs."""
command = ['journalctl', '--no-pager', '--lines=100', '--unit=plinth']
subprocess.run(command, check=True)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,17 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
hostname="$1"
if [ -d /run/systemd/system ] ; then
hostnamectl set-hostname --transient --static "$hostname"
else
echo "$hostname" > /etc/hostname
if [ -x /etc/init.d/hostname.sh ] ; then
invoke-rc.d hostname.sh start
else
service hostname start
fi
fi
service avahi-daemon restart

View File

@ -1,82 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Wrapper to list and handle system services
"""
import argparse
import os
from plinth import cfg
from plinth.modules.i2p.helpers import RouterEditor, TunnelEditor
cfg.read()
module_config_path = os.path.join(cfg.config_dir, 'modules-enabled')
I2P_CONF_DIR = '/var/lib/i2p/i2p-config'
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparser = subparsers.add_parser(
'add-favorite', help='Add an eepsite to the list of favorites')
subparser.add_argument('--name', help='Name of the entry', required=True)
subparser.add_argument('--url', help='URL of the entry', required=True)
subparser.add_argument('--description', help='Short description',
required=False)
subparser.add_argument('--icon', help='URL to icon', required=False)
subparser = subparsers.add_parser('set-tunnel-property',
help='Modify configuration of a tunnel')
subparser.add_argument('--name', help='Name of the tunnel', required=True)
subparser.add_argument('--property', help='Property to modify',
required=True)
subparser.add_argument('--value', help='Value to assign', required=True)
subparsers.required = True
return parser.parse_args()
def subcommand_set_tunnel_property(arguments):
"""Modify the configuration file for a certain tunnel."""
editor = TunnelEditor()
editor \
.read_conf() \
.set_tunnel_idx(arguments.name) \
.set_tunnel_prop(arguments.property, arguments.value) \
.write_conf()
print('Updated "{property}" of {filename} to {value}'.format(
property=editor.calc_prop_path(arguments.property),
filename=editor.conf_filename, value=arguments.value))
def subcommand_add_favorite(arguments):
"""
Adds a favorite to the router.config
:param arguments:
:type arguments:
"""
url = arguments.url
editor = RouterEditor()
editor.read_conf().add_favorite(arguments.name, url, arguments.description,
arguments.icon).write_conf()
print('Added {} to favorites'.format(url))
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,147 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for ikiwiki
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
SETUP_WIKI = '/etc/ikiwiki/plinth-wiki.setup'
SETUP_BLOG = '/etc/ikiwiki/plinth-blog.setup'
SITE_PATH = '/var/www/ikiwiki'
WIKI_PATH = '/var/lib/ikiwiki'
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
# Setup ikiwiki site
subparsers.add_parser('setup', help='Perform first time setup operations')
# Get wikis and blogs
subparsers.add_parser('get-sites', help='Get wikis and blogs')
# Create a wiki
create_wiki = subparsers.add_parser('create-wiki', help='Create a wiki')
create_wiki.add_argument('--wiki_name', help='Name of new wiki')
create_wiki.add_argument('--admin_name', help='Administrator account name')
# Create a blog
create_blog = subparsers.add_parser('create-blog', help='Create a blog')
create_blog.add_argument('--blog_name', help='Name of new blog')
create_blog.add_argument('--admin_name', help='Administrator account name')
# Delete a wiki or blog
delete = subparsers.add_parser('delete', help='Delete a wiki or blog.')
delete.add_argument('--name', help='Name of wiki or blog to delete.')
subparsers.required = True
return parser.parse_args()
def _is_safe_path(basedir, path):
"""Return whether a path is safe."""
return os.path.realpath(path).startswith(basedir)
def subcommand_setup(_):
"""Perform first time setup operations."""
setup()
def get_title(site):
"""Get blog or wiki title"""
try:
with open(os.path.join(SITE_PATH, site, 'index.html'),
encoding='utf-8') as index_file:
match = re.search(r'<title>(.*)</title>', index_file.read())
if match:
return match[1]
except FileNotFoundError:
pass
return site
def subcommand_get_sites(_):
"""Get wikis and blogs."""
if os.path.exists(SITE_PATH):
for site in os.listdir(SITE_PATH):
if not os.path.isdir(os.path.join(SITE_PATH, site)):
continue
title = get_title(site)
print(site, title)
def subcommand_create_wiki(arguments):
"""Create a wiki."""
pw_bytes = sys.stdin.read().encode()
proc = subprocess.Popen([
'ikiwiki', '-setup', SETUP_WIKI, arguments.wiki_name,
arguments.admin_name
], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE,
env=dict(os.environ, PERL_UNICODE='AS'))
outs, errs = proc.communicate(input=pw_bytes + b'\n' + pw_bytes)
print(outs)
print(errs)
def subcommand_create_blog(arguments):
"""Create a blog."""
pw_bytes = sys.stdin.read().encode()
proc = subprocess.Popen([
'ikiwiki', '-setup', SETUP_BLOG, arguments.blog_name,
arguments.admin_name
], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE,
env=dict(os.environ, PERL_UNICODE='AS'))
outs, errs = proc.communicate(input=pw_bytes + b'\n' + pw_bytes)
print(outs)
print(errs)
def subcommand_delete(arguments):
"""Delete a wiki or blog."""
html_folder = os.path.join(SITE_PATH, arguments.name)
wiki_folder = os.path.join(WIKI_PATH, arguments.name)
if not (_is_safe_path(SITE_PATH, html_folder)
and _is_safe_path(WIKI_PATH, wiki_folder)):
print('Error: {0} is not a correct name.'.format(arguments.name))
exit(1)
try:
shutil.rmtree(html_folder)
shutil.rmtree(wiki_folder)
shutil.rmtree(wiki_folder + '.git')
os.remove(wiki_folder + '.setup')
print('Deleted {0}'.format(arguments.name))
except FileNotFoundError:
print('Error: {0} not found.'.format(arguments.name))
exit(1)
def setup():
"""Write Apache configuration and wiki/blog setup scripts."""
if not os.path.exists(SITE_PATH):
os.makedirs(SITE_PATH)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,493 +1,9 @@
#!/usr/bin/python3 #!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Legacy configuration helper for Let's Encrypt, kept for compatibility.
LE configuration in the earlier implementation used to call into this script
with the sub-commands 'run-pre-hooks', 'run-renew-hooks' and 'run-post-hooks'.
This action script now allows for any arbitrary sub-command to be called and
does nothing. It can be removed after the release of Debian 12 (bookworm).
""" """
Configuration helper for Let's Encrypt.
"""
import argparse
import filecmp
import glob
import importlib
import inspect
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import configobj
from plinth import action_utils
from plinth import app as app_module
from plinth import cfg
from plinth.modules import letsencrypt as le
from plinth.modules.letsencrypt.components import LetsEncrypt
TEST_MODE = False
LE_DIRECTORY = '/etc/letsencrypt/'
ETC_SSL_DIRECTORY = '/etc/ssl/'
RENEWAL_DIRECTORY = '/etc/letsencrypt/renewal/'
AUTHENTICATOR = 'webroot'
WEB_ROOT_PATH = '/var/www/html'
APACHE_PREFIX = '/etc/apache2/sites-available/'
APACHE_CONFIGURATION = '''
Use FreedomBoxTLSSiteMacro {domain}
'''
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
setup_parser = subparsers.add_parser(
'setup', help='Run any setup/upgrade activities.')
setup_parser.add_argument(
'--old-version', type=int, required=True,
help='Version number being upgraded from or None if setting up first '
'time.')
subparsers.add_parser('get-status',
help='Return the status of configured domains.')
subparser = subparsers.add_parser(
'get-modified-time',
help='Return the modified time for a certificate.')
subparser.add_argument('--domain', required=True,
help='Domain name to get modified time for')
revoke_parser = subparsers.add_parser(
'revoke', help='Revoke certificate of a domain and disable website.')
revoke_parser.add_argument('--domain', required=True,
help='Domain name to revoke certificate for')
obtain_parser = subparsers.add_parser(
'obtain', help='Obtain certificate for a domain and setup website.')
obtain_parser.add_argument('--domain', required=True,
help='Domain name to obtain certificate for')
delete_parser = subparsers.add_parser(
'delete', help='Delete certificate for a domain and disable website.')
delete_parser.add_argument('--domain', required=True,
help='Domain name to delete certificate of')
subparser = subparsers.add_parser(
'copy-certificate',
help='Copy LE certificate to a daemon\'s directory')
subparser.add_argument('--managing-app', required=True,
help='App needing the certificate')
subparser.add_argument('--user-owner', required=True,
help='User who should own the certificate')
subparser.add_argument('--group-owner', required=True,
help='Group that should own the certificate')
subparser.add_argument('--source-private-key-path', required=True,
help='Path to the source private key')
subparser.add_argument(
'--source-certificate-path', required=True,
help='Path to the source certificate with public key')
subparser.add_argument('--private-key-path', required=True,
help='Path to the private key')
subparser.add_argument('--certificate-path', required=True,
help='Path to the certificate with public key')
subparser = subparsers.add_parser(
'compare-certificate',
help='Compare LE certificate to one in daemon\'s directory')
subparser.add_argument('--managing-app', required=True,
help='App needing the certificate')
subparser.add_argument('--source-private-key-path', required=True,
help='Path to the source private key')
subparser.add_argument(
'--source-certificate-path', required=True,
help='Path to the source certificate with public key')
subparser.add_argument('--private-key-path', required=True,
help='Path to the private key')
subparser.add_argument('--certificate-path', required=True,
help='Path to the certificate with public key')
help_hooks = 'Does nothing, kept for compatibility.'
subparser = subparsers.add_parser('run_pre_hooks', help=help_hooks)
subparser.add_argument('--domain')
subparser.add_argument('--modules', nargs='+', default=[])
subparser = subparsers.add_parser('run_renew_hooks', help=help_hooks)
subparser.add_argument('--domain')
subparser.add_argument('--modules', nargs='+', default=[])
subparser = subparsers.add_parser('run_post_hooks', help=help_hooks)
subparser.add_argument('--domain')
subparser.add_argument('--modules', nargs='+', default=[])
subparsers.required = True
return parser.parse_args()
def get_certificate_expiry(domain):
"""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])
return output.decode().strip().split('=')[1]
def get_modified_time(domain):
"""Return the last modified time of a certificate."""
certificate_file = pathlib.Path(le.LIVE_DIRECTORY) / domain / 'cert.pem'
return int(certificate_file.stat().st_mtime)
def get_validity_status(domain):
"""Return validity status of a certificate, e.g. valid, revoked, expired"""
output = subprocess.check_output(['certbot', 'certificates', '-d', domain])
output = output.decode(sys.stdout.encoding)
match = re.search(r'INVALID: (.*)\)', output)
if match is not None:
validity = match.group(1).lower()
elif re.search('VALID', output) is not None:
validity = 'valid'
else:
validity = 'unknown'
return validity
def get_status():
"""
Return Python dictionary of currently configured domains.
Should be run as root, otherwise might yield a wrong, empty answer.
"""
try:
domains = os.listdir(le.LIVE_DIRECTORY)
except OSError:
domains = []
domains = [
domain for domain in domains
if os.path.isdir(os.path.join(le.LIVE_DIRECTORY, domain))
]
domain_status = {}
for domain in domains:
domain_status[domain] = {
'certificate_available':
True,
'expiry_date':
get_certificate_expiry(domain),
'web_enabled':
action_utils.webserver_is_enabled(domain, kind='site'),
'validity':
get_validity_status(domain),
'lineage':
str(pathlib.Path(le.LIVE_DIRECTORY) / domain),
'modified_time':
get_modified_time(domain)
}
return domain_status
def subcommand_setup(arguments):
"""Upgrade old site configuration to new macro based style.
Nothing to do for first time setup and for newer versions.
"""
if arguments.old_version == 2:
_remove_old_hooks()
return
if arguments.old_version != 1:
return
domain_status = get_status()
with action_utils.WebserverChange() as webserver_change:
for domain in domain_status:
setup_webserver_config(domain, webserver_change)
def subcommand_get_status(_):
"""Print a JSON dictionary of currently configured domains."""
domain_status = get_status()
print(json.dumps({'domains': domain_status}))
def subcommand_get_modified_time(arguments):
"""Print the modified time of a certificate as integer."""
print(get_modified_time(arguments.domain))
def subcommand_revoke(arguments):
"""Disable a domain and revoke the certificate."""
domain = arguments.domain
cert_path = pathlib.Path(le.LIVE_DIRECTORY) / domain / 'cert.pem'
if cert_path.exists():
command = [
'certbot', 'revoke', '--non-interactive', '--domain', domain,
'--cert-path', cert_path
]
if TEST_MODE:
command.append('--staging')
process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
_, stderr = process.communicate()
if process.returncode:
print(stderr.decode(), file=sys.stderr)
sys.exit(1)
action_utils.webserver_disable(domain, kind='site')
def subcommand_obtain(arguments):
"""Obtain a certificate for a domain and setup website."""
domain = arguments.domain
command = [
'certbot', 'certonly', '--non-interactive', '--text', '--agree-tos',
'--register-unsafely-without-email', '--domain', arguments.domain,
'--authenticator', AUTHENTICATOR, '--webroot-path', WEB_ROOT_PATH,
'--renew-by-default'
]
if TEST_MODE:
command.append('--staging')
process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
_, stderr = process.communicate()
if process.returncode:
print(stderr.decode(), file=sys.stderr)
sys.exit(1)
with action_utils.WebserverChange() as webserver_change:
setup_webserver_config(domain, webserver_change)
def _remove_old_hooks():
"""Remove old style renewal hooks from individual configuration files.
This has been replaced with global hooks by adding script files in
directory /etc/letsencrypt/renewal-hooks/{pre,post,deploy}/.
"""
for file_path in glob.glob(RENEWAL_DIRECTORY + '*.conf'):
try:
_remove_old_hooks_from_file(file_path)
except Exception as exception:
print('Error removing hooks from file:', file_path, exception)
def _remove_old_hooks_from_file(file_path):
"""Remove old style hooks from a single configuration file."""
config = configobj.ConfigObj(file_path)
edited = False
for line in config.initial_comment:
if 'edited by plinth' in line.lower():
edited = True
if not edited:
return
config.initial_comment = [
line for line in config.initial_comment
if 'edited by plinth' not in line.lower()
]
if 'pre_hook' in config['renewalparams']:
del config['renewalparams']['pre_hook']
if 'renew_hook' in config['renewalparams']:
del config['renewalparams']['renew_hook']
if 'post_hook' in config['renewalparams']:
del config['renewalparams']['post_hook']
config.write()
def subcommand_copy_certificate(arguments):
"""Copy certificate from LE directory to daemon's directory.
Set ownership and permissions as requested needed by the daemon.
"""
source_private_key_path = pathlib.Path(
arguments.source_private_key_path).resolve()
_assert_source_directory(source_private_key_path)
source_certificate_path = pathlib.Path(
arguments.source_certificate_path).resolve()
_assert_source_directory(source_certificate_path)
private_key_path = pathlib.Path(arguments.private_key_path).resolve()
_assert_managed_path(arguments.managing_app, private_key_path)
certificate_path = pathlib.Path(arguments.certificate_path).resolve()
_assert_managed_path(arguments.managing_app, certificate_path)
# Create directories, owned by root
private_key_path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
certificate_path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
# Private key is only accessible to the user owner
old_mask = os.umask(0o177)
shutil.copyfile(source_private_key_path, private_key_path)
if certificate_path != private_key_path:
# Certificate is only writable by the user owner
os.umask(0o133)
shutil.copyfile(source_certificate_path, certificate_path)
else:
# If private key and certificate are the same file, append one after
# the other.
source_certificate = source_certificate_path.read_bytes()
with private_key_path.open(mode='a+b') as file_handle:
file_handle.write(source_certificate)
os.umask(old_mask)
shutil.chown(certificate_path, user=arguments.user_owner,
group=arguments.group_owner)
shutil.chown(private_key_path, user=arguments.user_owner,
group=arguments.group_owner)
def subcommand_compare_certificate(arguments):
"""Compare LE certificate with an app certificate."""
source_private_key_path = pathlib.Path(arguments.source_private_key_path)
source_certificate_path = pathlib.Path(arguments.source_certificate_path)
_assert_source_directory(source_private_key_path)
_assert_source_directory(source_certificate_path)
private_key_path = pathlib.Path(arguments.private_key_path)
certificate_path = pathlib.Path(arguments.certificate_path)
_assert_managed_path(arguments.managing_app, private_key_path)
_assert_managed_path(arguments.managing_app, certificate_path)
result = False
try:
if filecmp.cmp(source_certificate_path, certificate_path) and \
filecmp.cmp(source_private_key_path, private_key_path):
result = True
except FileNotFoundError:
result = False
print(json.dumps({'result': result}))
def _assert_source_directory(path):
"""Assert that a path is a valid source of a certificates."""
assert (str(path).startswith(LE_DIRECTORY)
or str(path).startswith(ETC_SSL_DIRECTORY))
def _get_managed_path(path):
"""Return the managed path given a certificate path."""
if '{domain}' in path:
return pathlib.Path(path.partition('{domain}')[0])
return pathlib.Path(path).parent
def _assert_managed_path(module, path):
"""Check that path is in fact managed by module."""
cfg.read()
module_file = pathlib.Path(cfg.config_dir) / 'modules-enabled' / module
module_path = module_file.read_text().strip()
module = importlib.import_module(module_path)
module_classes = inspect.getmembers(module, inspect.isclass)
app_classes = [
cls[1] for cls in module_classes if issubclass(cls[1], app_module.App)
]
managed_paths = []
for cls in app_classes:
app = cls()
components = app.get_components_of_type(LetsEncrypt)
for component in components:
if component.private_key_path:
managed_paths.append(
_get_managed_path(component.private_key_path))
if component.certificate_path:
managed_paths.append(
_get_managed_path(component.certificate_path))
assert set(path.parents).intersection(set(managed_paths))
def subcommand_run_pre_hooks(_):
"""Do nothing, kept for legacy LE configuration.
If new version of Plinth is deployed and before it can update the Let's
Encrypt configuration and remove these old hooks, if a renew operation is
run, then we don't want it to exit with non-zero error code because this
hook could not be run.
Remove at some point in the future.
"""
def subcommand_run_renew_hooks(_):
"""Do nothing, kept for legacy LE configuration.
If new version of Plinth is deployed and before it can update the Let's
Encrypt configuration and remove these old hooks, if a renew operation is
run, then we don't want it to exit with non-zero error code because this
hook could not be run.
Remove at some point in the future.
"""
def subcommand_run_post_hooks(_):
"""Do nothing, kept for legacy LE configuration.
If new version of Plinth is deployed and before it can update the Let's
Encrypt configuration and remove these old hooks, if a renew operation is
run, then we don't want it to exit with non-zero error code because this
hook could not be run.
Remove at some point in the future.
"""
def subcommand_delete(arguments):
"""Disable a domain and delete the certificate."""
domain = arguments.domain
command = ['certbot', 'delete', '--non-interactive', '--cert-name', domain]
process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
_, stderr = process.communicate()
if process.returncode:
print(stderr.decode(), file=sys.stderr)
sys.exit(1)
action_utils.webserver_disable(domain, kind='site')
def setup_webserver_config(domain, webserver_change):
"""Create SSL web server configuration for a domain.
Do so only if there is no configuration existing.
"""
file_name = os.path.join(APACHE_PREFIX, domain + '.conf')
if os.path.isfile(file_name):
os.rename(file_name, file_name + '.fbx-bak')
with open(file_name, 'w', encoding='utf-8') as file_handle:
file_handle.write(APACHE_CONFIGURATION.format(domain=domain))
webserver_change.enable('freedombox-tls-site-macro', kind='config')
webserver_change.enable(domain, kind='site')
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,96 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for Minetest server.
"""
import argparse
import augeas
from plinth import action_utils
CONFIG_FILE = '/etc/minetest/minetest.conf'
AUG_PATH = '/files' + CONFIG_FILE + '/.anon'
def parse_arguments():
"""Return parsed command line arguments as dictionary"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
configure = subparsers.add_parser('configure', help='Configure Minetest')
configure.add_argument('--max_players',
help='Set maximum number of players')
configure.add_argument('--creative_mode', choices=['true', 'false'],
help='Set creative mode true/false')
configure.add_argument('--enable_pvp', choices=['true', 'false'],
help='Set player Vs player true/false')
configure.add_argument('--enable_damage', choices=['true', 'false'],
help='Set damage true/false')
subparsers.required = True
return parser.parse_args()
def subcommand_configure(arguments):
"""Configure Minetest."""
aug = load_augeas()
if arguments.max_players:
set_max_players(aug, arguments.max_players)
if arguments.creative_mode:
set_creative_mode(aug, arguments.creative_mode)
if arguments.enable_pvp:
enable_pvp(aug, arguments.enable_pvp)
if arguments.enable_damage:
enable_damage(aug, arguments.enable_damage)
action_utils.service_restart('minetest-server')
def set_max_players(aug, max_players):
"""Sets the number of max players"""
aug.set(AUG_PATH + '/max_users', max_players)
aug.save()
def enable_pvp(aug, choice):
"""Enables pvp"""
aug.set(AUG_PATH + '/enable_pvp', choice)
aug.save()
def set_creative_mode(aug, choice):
"""Enables or disables creative mode"""
aug.set(AUG_PATH + '/creative_mode', choice)
aug.save()
def enable_damage(aug, choice):
"""Enables damage"""
aug.set(AUG_PATH + '/enable_damage', choice)
aug.save()
def load_augeas():
"""Initialize Augeas."""
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/Php/lens', 'Php.lns')
aug.set('/augeas/load/Php/incl[last() + 1]', CONFIG_FILE)
aug.load()
return aug
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,161 +0,0 @@
#!/bin/bash
set -e
# Configure networking for all wired and wireless devices.
#
# Creates network-manager connections.
function get-interfaces {
WIRED_IFACES=$(nmcli --terse --fields type,device device | grep "^ethernet:" | cut -d: -f2 | sort -V)
NO_OF_WIRED_IFACES=$(echo $WIRED_IFACES | wc -w)
WIRELESS_IFACES=$(nmcli --terse --fields type,device device | grep "^wifi:" | cut -d: -f2 | sort -V)
NO_OF_WIRELESS_IFACES=$(echo $WIRELESS_IFACES | wc -w)
}
function add-connection {
local connection_name="$1"
shift
local interface="$1"
shift
local remaining_arguments="$@"
already_exists=$(nmcli --terse --fields name,device con show | grep "$connection_name:$interface" || true)
if [ -n "$already_exists" ]; then
echo "Connection '$connection_name' already exists for device '$interface', not adding."
else
nmcli con add con-name "$connection_name" ifname "$interface" $remaining_arguments
fi
}
function activate-connection {
connection_name="$1"
nohup nmcli con up "$connection_name" &>/dev/null &
}
function configure-regular-interface {
local interface="$1"
local zone="$2"
local connection_name="FreedomBox WAN"
# Create n-m connection for a regular interface
add-connection "$connection_name" "$interface" type ethernet
nmcli con modify "$connection_name" connection.autoconnect TRUE
nmcli con modify "$connection_name" connection.zone "$zone"
activate-connection "$connection_name"
echo "Configured interface '$interface' for '$zone' use as '$connection_name'."
}
function configure-shared-interface {
local interface="$1"
local connection_name="FreedomBox LAN $interface"
# Create n-m connection for eth1
add-connection "$connection_name" "$interface" type ethernet
nmcli con modify "$connection_name" connection.autoconnect TRUE
nmcli con modify "$connection_name" connection.zone internal
# Configure this interface to be shared with other computers.
# - Self-assign an address and network
# - Start and manage DNS server (dnsmasq)
# - Start and manage DHCP server (dnsmasq)
# - Register address with mDNS
# - Add firewall rules for NATing from this interface
nmcli con modify "$connection_name" ipv4.method shared
activate-connection "$connection_name"
echo "Configured interface '$interface' for shared use as '$connection_name'."
}
function configure-wireless-interface {
local interface="$1"
local connection_name="FreedomBox $interface"
local ssid="FreedomBox$interface"
local secret="freedombox123"
add-connection "$connection_name" "$interface" type wifi ssid "$ssid"
nmcli con modify "$connection_name" connection.autoconnect TRUE
nmcli con modify "$connection_name" connection.zone internal
nmcli con modify "$connection_name" ipv4.method shared
nmcli con modify "$connection_name" wifi.mode ap
nmcli con modify "$connection_name" wifi-sec.key-mgmt wpa-psk
nmcli con modify "$connection_name" wifi-sec.psk "$secret"
activate-connection "$connection_name"
echo "Configured interface '$interface' for shared use as '$connection_name'."
}
function multi-wired-setup {
local first_interface="$1"
shift
local remaining_interfaces="$@"
configure-regular-interface "$first_interface" external
for interface in $remaining_interfaces
do
configure-shared-interface "$interface"
done
}
function one-wired-setup {
local interface="$1"
case $NO_OF_WIRELESS_IFACES in
"0")
configure-regular-interface "$interface" internal
;;
*)
configure-regular-interface "$interface" external
;;
esac
}
function wireless-setup {
local interfaces="$@"
for interface in $interfaces
do
configure-wireless-interface "$interface"
done
}
function setup {
echo "Setting up network configuration..."
get-interfaces
case $NO_OF_WIRED_IFACES in
"0")
echo "No wired interfaces detected."
;;
"1")
one-wired-setup $WIRED_IFACES
;;
*)
multi-wired-setup $WIRED_IFACES
esac
wireless-setup $WIRELESS_IFACES
echo "Done setting up network configuration."
}
#
# For a user who installed using freedombox-setup Debian package, when
# FreedomBox Service (Plinth) is run for the first time, don't run network
# setup. This is ensured by checking for the file
# /var/lib/freedombox/is-freedombox-disk-image which will not exist.
#
# For a user who installed using FreedomBox disk image, when FreedomBox Service
# (Plinth) runs for the first time, setup process executes and triggers the
# script due networks module being an essential module.
#
if [ -f "/var/lib/freedombox/is-freedombox-disk-image" ]
then
setup
else
echo "Not a FreedomBox disk image. Skipping network configuration."
fi

View File

@ -1,43 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for power controls.
"""
import argparse
import subprocess
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('restart', help='Restart the system')
subparsers.add_parser('shutdown', help='Shut down the system')
subparsers.required = True
return parser.parse_args()
def subcommand_restart(_):
"""Restart the system."""
subprocess.call('reboot')
def subcommand_shutdown(_):
"""Shut down the system."""
subprocess.call(['shutdown', 'now'])
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,40 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for Quassel.
"""
import argparse
import pathlib
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparser = subparsers.add_parser('set-domain',
help='Setup Quassel configuration')
subparser.add_argument('domain_name', help='Domain name to be allowed')
subparsers.required = True
return parser.parse_args()
def subcommand_set_domain(arguments):
"""Write a file containing domain name."""
domain_file = pathlib.Path('/var/lib/quassel/domain-freedombox')
domain_file.write_text(arguments.domain_name, encoding='utf-8')
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,76 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for Radicale.
"""
import argparse
import os
import augeas
from plinth import action_utils
CONFIG_FILE = '/etc/radicale/config'
LOG_PATH = '/var/log/radicale'
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
configure = subparsers.add_parser('configure',
help='Configure various options')
configure.add_argument('--rights_type',
help='Set the rights type for radicale')
subparsers.add_parser('fix-paths', help='Ensure paths exists')
subparsers.required = True
return parser.parse_args()
def subcommand_configure(arguments):
"""Sets the radicale rights type to a particular value"""
if arguments.rights_type == 'owner_only':
# Default rights file is equivalent to owner_only.
arguments.rights_type = 'from_file'
aug = load_augeas()
aug.set('/files' + CONFIG_FILE + '/rights/type', arguments.rights_type)
aug.save()
action_utils.service_try_restart('uwsgi')
def subcommand_fix_paths(_):
"""Fix log path to work around a bug."""
# Workaround for bug in radicale's uwsgi script (#931201)
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH)
def load_augeas():
"""Initialize Augeas."""
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
# INI file lens
aug.set('/augeas/load/Puppet/lens', 'Puppet.lns')
aug.set('/augeas/load/Puppet/incl[last() + 1]', CONFIG_FILE)
aug.load()
return aug
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,67 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Helper for security configuration
"""
import argparse
import os
from plinth.modules.security import (ACCESS_CONF_FILE, ACCESS_CONF_FILE_OLD,
ACCESS_CONF_SNIPPET, ACCESS_CONF_SNIPPETS)
def parse_arguments():
"""Return parsed command line arguments as dictionary"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser(
'enable-restricted-access',
help='Restrict console login to users in admin or sudo group')
subparsers.add_parser(
'disable-restricted-access',
help='Don\'t restrict console login to users in admin or sudo group')
subparsers.required = True
return parser.parse_args()
def subcommand_enable_restricted_access(_):
"""Restrict console login to users in admin or sudo group."""
try:
os.mkdir(os.path.dirname(ACCESS_CONF_FILE))
except FileExistsError:
pass
with open(ACCESS_CONF_FILE, 'w', encoding='utf-8') as conffile:
conffile.write(ACCESS_CONF_SNIPPET + '\n')
def subcommand_disable_restricted_access(_):
"""Don't restrict console login to users in admin or sudo group."""
with open(ACCESS_CONF_FILE_OLD, 'r', encoding='utf-8') as conffile:
lines = conffile.readlines()
with open(ACCESS_CONF_FILE_OLD, 'w', encoding='utf-8') as conffile:
for line in lines:
if line.strip() not in ACCESS_CONF_SNIPPETS:
conffile.write(line)
try:
os.remove(ACCESS_CONF_FILE)
except OSError:
pass
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,130 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Wrapper to list and handle system services
"""
import argparse
import os
from plinth import action_utils
from plinth import app as app_module
from plinth import cfg, module_loader
from plinth.daemon import Daemon, RelatedDaemon
cfg.read()
module_config_path = os.path.join(cfg.config_dir, 'modules-enabled')
def add_service_action(subparsers, action, help):
parser = subparsers.add_parser(action, help=help)
parser.add_argument('service', help='name of the service')
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
add_service_action(subparsers, 'start', 'start a service')
add_service_action(subparsers, 'stop', 'stop a service')
add_service_action(subparsers, 'enable', 'enable a service')
add_service_action(subparsers, 'disable', 'disable a service')
add_service_action(subparsers, 'restart', 'restart a service')
add_service_action(subparsers, 'try-restart',
'restart a service if running')
add_service_action(subparsers, 'reload', 'reload a service')
add_service_action(subparsers, 'is-running', 'status of a service')
add_service_action(subparsers, 'is-enabled', 'status a service')
add_service_action(subparsers, 'mask', 'unmask a service')
add_service_action(subparsers, 'unmask', 'unmask a service')
subparsers.required = True
return parser.parse_args()
def subcommand_start(arguments):
action_utils.service_start(arguments.service)
def subcommand_stop(arguments):
action_utils.service_stop(arguments.service)
def subcommand_enable(arguments):
action_utils.service_enable(arguments.service)
def subcommand_disable(arguments):
action_utils.service_disable(arguments.service)
def subcommand_restart(arguments):
action_utils.service_restart(arguments.service)
def subcommand_try_restart(arguments):
action_utils.service_try_restart(arguments.service)
def subcommand_reload(arguments):
action_utils.service_reload(arguments.service)
def subcommand_mask(arguments):
action_utils.service_mask(arguments.service)
def subcommand_unmask(arguments):
action_utils.service_unmask(arguments.service)
def subcommand_is_enabled(arguments):
print(action_utils.service_is_enabled(arguments.service))
def subcommand_is_running(arguments):
print(action_utils.service_is_running(arguments.service))
def _get_managed_services():
"""Get a set of all services managed by FreedomBox."""
services = set()
module_loader.load_modules()
app_module.apps_init()
for app in app_module.App.list():
components = app.get_components_of_type(Daemon)
for component in components:
services.add(component.unit)
if component.alias:
services.add(component.alias)
components = app.get_components_of_type(RelatedDaemon)
for component in components:
services.add(component.unit)
return services
def _assert_service_is_managed_by_plinth(service_name):
managed_services = _get_managed_services()
if service_name not in managed_services:
msg = ("The service '%s' is not managed by FreedomBox. Access is only "
"permitted for services listed in the 'managed_services' "
"variable of any FreedomBox app.") % service_name
raise ValueError(msg)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
if hasattr(arguments, 'service'):
_assert_service_is_managed_by_plinth(arguments.service)
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,208 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for the sharing app.
"""
import argparse
import json
import os
import pathlib
import re
import augeas
from plinth import action_utils
APACHE_CONFIGURATION = '/etc/apache2/conf-available/sharing-freedombox.conf'
def parse_arguments():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('list', help='List all existing shares')
add_parser = subparsers.add_parser('add', help='Add a new share')
add_parser.add_argument('--name', required=True, help='Name of the share')
add_parser.add_argument('--path', required=True, help='Disk path to share')
add_parser.add_argument('--groups', nargs='*',
help='List of groups that can access the share')
add_parser.add_argument('--is-public', required=False, default=False,
action="store_true",
help='Allow public access to this share')
remove_parser = subparsers.add_parser('remove',
help='Remove an existing share')
remove_parser.add_argument('--name', required=True,
help='Name of the share to remove')
subparsers.required = True
return parser.parse_args()
def load_augeas():
"""Initialize augeas for this app's configuration file."""
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/Httpd/lens', 'Httpd.lns')
aug.set('/augeas/load/Httpd/incl[last() + 1]', APACHE_CONFIGURATION)
aug.load()
aug.defvar('conf', '/files' + APACHE_CONFIGURATION)
return aug
def subcommand_add(arguments):
"""Add a share to Apache configuration."""
name = arguments.name
path = '"' + arguments.path.replace('"', r'\"') + '"'
groups = arguments.groups
is_public = arguments.is_public
url = '/share/' + name
if not os.path.exists(APACHE_CONFIGURATION):
pathlib.Path(APACHE_CONFIGURATION).touch()
aug = load_augeas()
shares = _list(aug)
if any([share for share in shares if share['name'] == name]):
raise Exception('Share already present')
aug.set('$conf/directive[last() + 1]', 'Alias')
aug.set('$conf/directive[last()]/arg[1]', url)
aug.set('$conf/directive[last()]/arg[2]', path)
aug.set('$conf/Location[last() + 1]/arg', url)
aug.set('$conf/Location[last()]/directive[last() + 1]', 'Include')
aug.set('$conf/Location[last()]/directive[last()]/arg',
'includes/freedombox-sharing.conf')
if not is_public:
aug.set('$conf/Location[last()]/directive[last() + 1]', 'Include')
aug.set('$conf/Location[last()]/directive[last()]/arg',
'includes/freedombox-single-sign-on.conf')
aug.set('$conf/Location[last()]/IfModule/arg', 'mod_auth_pubtkt.c')
aug.set('$conf/Location[last()]/IfModule/directive[1]', 'TKTAuthToken')
for group_name in groups:
aug.set(
'$conf/Location[last()]/IfModule/directive[1]/arg[last() + 1]',
group_name)
else:
aug.set('$conf/Location[last()]/directive[last() + 1]', 'Require')
aug.set('$conf/Location[last()]/directive[last()]/arg[1]', 'all')
aug.set('$conf/Location[last()]/directive[last()]/arg[2]', 'granted')
aug.save()
with action_utils.WebserverChange() as webserver_change:
webserver_change.enable('sharing-freedombox')
def subcommand_remove(arguments):
"""Remove a share from Apache configuration."""
url_to_remove = '/share/' + arguments.name
aug = load_augeas()
for directive in aug.match('$conf/directive'):
if aug.get(directive) != 'Alias':
continue
url = aug.get(directive + '/arg[1]')
if url == url_to_remove:
aug.remove(directive)
for location in aug.match('$conf/Location'):
url = aug.get(location + '/arg')
if url == url_to_remove:
aug.remove(location)
aug.save()
with action_utils.WebserverChange() as webserver_change:
webserver_change.enable('sharing-freedombox')
def _get_name_from_url(url):
"""Return the name of the share given the URL for it."""
matches = re.match(r'/share/([a-z0-9\-]*)', url)
if not matches:
raise ValueError
return matches[1]
def _list(aug=None):
"""List all Apache configuration shares."""
if not aug:
aug = load_augeas()
shares = []
for match in aug.match('$conf/directive'):
if aug.get(match) != 'Alias':
continue
url = aug.get(match + '/arg[1]')
path = aug.get(match + '/arg[2]')
path = path.removesuffix('"').removeprefix('"')
path = path.replace(r'\"', '"')
try:
name = _get_name_from_url(url)
shares.append({
'name': name,
'path': path,
'url': '/share/' + name
})
except ValueError:
continue
for location in aug.match('$conf/Location'):
url = aug.get(location + '/arg')
try:
name = _get_name_from_url(url)
except ValueError:
continue
groups = []
for group in aug.match(location + '//directive["TKTAuthToken"]/arg'):
groups.append(aug.get(group))
def _is_public():
"""Must contain the line 'Require all granted'."""
require = location + '//directive["Require"]'
return bool(aug.match(require)) and aug.get(
require +
'/arg[1]') == 'all' and aug.get(require +
'/arg[2]') == 'granted'
for share in shares:
if share['name'] == name:
share['groups'] = groups
share['is_public'] = _is_public()
return shares
def subcommand_list(_):
"""List all Apache configuration shares and print as JSON."""
print(json.dumps({'shares': _list()}))
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,137 +0,0 @@
#!/usr/bin/python3
# -*- mode: python -*-
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Actions for sshfs.
"""
import argparse
import json
import os
import subprocess
import sys
TIMEOUT = 30
class AlreadyMountedError(Exception):
"""Exception raised when mount point is already mounted."""
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
mount = subparsers.add_parser('mount', help='mount an ssh filesystem')
mount.add_argument('--mountpoint', help='Local mountpoint', required=True)
mount.add_argument('--path', help='Remote ssh path to mount',
required=True)
mount.add_argument('--ssh-keyfile', help='Path of private ssh key',
default=None, required=False)
mount.add_argument('--user-known-hosts-file',
help='Path to a custom known_hosts file',
default='/dev/null')
umount = subparsers.add_parser('umount', help='unmount an ssh filesystem')
umount.add_argument('--mountpoint', help='Mountpoint to unmount',
required=True)
is_mounted = subparsers.add_parser(
'is-mounted', help='Check whether a mountpoint is mounted')
is_mounted.add_argument('--mountpoint', help='Mountpoint to check',
required=True)
subparsers.required = True
return parser.parse_args()
def subcommand_mount(arguments):
"""Mount a remote ssh path via sshfs."""
try:
validate_mountpoint(arguments.mountpoint)
except AlreadyMountedError:
return
remote_path = arguments.path
kwargs = {}
# the shell would expand ~/ to the local home directory
remote_path = remote_path.replace('~/', '').replace('~', '')
# 'reconnect', 'ServerAliveInternal' and 'ServerAliveCountMax' allow the
# client (FreedomBox) to keep control of the SSH connection even when the
# SSH server misbehaves. Without these options, other commands such as
# '/usr/share/plinth/actions/storage usage-info', 'df',
# '/usr/share/plinth/actions/sshfs is-mounted', or 'mountpoint' might block
# indefinitely (even when manually invoked from the command line). This
# situation has some lateral effects, causing major system instability in
# the course of ~11 days, and leaving the system in such state that the
# only solution is a reboot.
cmd = [
'sshfs', remote_path, arguments.mountpoint, '-o',
f'UserKnownHostsFile={arguments.user_known_hosts_file}', '-o',
'StrictHostKeyChecking=yes', '-o', 'reconnect', '-o',
'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'
]
if arguments.ssh_keyfile:
cmd += ['-o', 'IdentityFile=' + arguments.ssh_keyfile]
else:
password = read_password()
if not password:
raise ValueError('mount requires either a password or ssh_keyfile')
cmd += ['-o', 'password_stdin']
kwargs['input'] = password.encode()
subprocess.run(cmd, check=True, timeout=TIMEOUT, **kwargs)
def subcommand_umount(arguments):
"""Unmount a mountpoint."""
subprocess.run(['umount', arguments.mountpoint], check=True)
def validate_mountpoint(mountpoint):
"""Check that the folder is empty, and create it if it doesn't exist"""
if os.path.exists(mountpoint):
if _is_mounted(mountpoint):
raise AlreadyMountedError('Mountpoint %s already mounted' %
mountpoint)
if os.listdir(mountpoint) or not os.path.isdir(mountpoint):
raise ValueError('Mountpoint %s is not an empty directory' %
mountpoint)
else:
os.makedirs(mountpoint)
def _is_mounted(mountpoint):
"""Return boolean whether a local directory is a mountpoint."""
cmd = ['mountpoint', '-q', mountpoint]
# mountpoint exits with status non-zero if it didn't find a mountpoint
try:
subprocess.run(cmd, check=True)
return True
except subprocess.CalledProcessError:
return False
def subcommand_is_mounted(arguments):
"""Print whether a path is already mounted."""
print(json.dumps(_is_mounted(arguments.mountpoint)))
def read_password():
"""Read the password from stdin."""
if sys.stdin.isatty():
return ''
return ''.join(sys.stdin)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -1,37 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Set time zones with timedatectl (requires root permission).
"""
import argparse
import subprocess
import sys
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
parser.add_argument(
'timezone', help='Time zone to set; see "timedatectl list-timezones".')
return parser.parse_args()
def _set_timezone(arguments):
"""Set time zone with timedatectl."""
try:
command = ['timedatectl', 'set-timezone', arguments.timezone]
subprocess.run(command, stdout=subprocess.DEVNULL, check=True)
except subprocess.CalledProcessError as exception:
print('Error setting timezone:', exception, file=sys.stderr)
sys.exit(1)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
_set_timezone(arguments)
if __name__ == '__main__':
main()

View File

@ -148,24 +148,39 @@ def fixture_actions_module(request):
return module return module
@pytest.fixture(name='call_action') @pytest.fixture(name='mock_privileged')
def fixture_call_action(request, capsys, actions_module): def fixture_mock_privileged(request):
"""Run actions with custom root path.""" """Mock the privileged decorator to nullify its effects."""
try:
privileged_modules_to_mock = request.module.privileged_modules_to_mock
except AttributeError:
raise AttributeError(
'mock_privileged fixture requires "privileged_module_to_mock" '
'attribute at module level')
actions_name = getattr(request.module, 'actions_name') for module_name in privileged_modules_to_mock:
module = importlib.import_module(module_name)
for name, member in module.__dict__.items():
wrapped = getattr(member, '__wrapped__', None)
if not callable(member) or not wrapped:
continue
def _call_action(*args, **_kwargs): if not getattr(member, '_privileged', False):
if isinstance(args[0], list): continue
argv = [actions_name] + args[0] # Command line style usage
else:
argv = [args[0]] + args[1] # superuser_run style usage
with patch('argparse._sys.argv', argv): setattr(wrapped, '_original_wrapper', member)
actions_module.main() module.__dict__[name] = wrapped
captured = capsys.readouterr()
return captured.out
return _call_action yield
for module_name in privileged_modules_to_mock:
module = importlib.import_module(module_name)
for name, member in module.__dict__.items():
wrapper = getattr(member, '_original_wrapper', None)
if not callable(member) or not wrapper:
continue
module.__dict__[name] = wrapper
@pytest.fixture(name='splinter_screenshot_dir', scope='session') @pytest.fixture(name='splinter_screenshot_dir', scope='session')

View File

@ -193,7 +193,7 @@ mount -o remount /freedombox
if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]] if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]]
then then
echo "> In container: Enable backports" echo "> In container: Enable backports"
/freedombox/actions/upgrades activate-backports /freedombox/actions/actions upgrades activate_backports --no-args
fi fi
echo "> In container: Upgrade packages" echo "> In container: Upgrade packages"
@ -650,8 +650,10 @@ def _setup_users(image_file):
str(gid), 'plinth'], stdout=subprocess.DEVNULL) str(gid), 'plinth'], stdout=subprocess.DEVNULL)
logger.info('In container: Setting up sudo for users "fbx" and "plinth"') logger.info('In container: Setting up sudo for users "fbx" and "plinth"')
sudo_config = 'plinth ALL=(ALL:ALL) NOPASSWD:SETENV : ' \ sudo_config = 'Cmnd_Alias FREEDOMBOX_ACTION_DEV = /usr/share/plinth/' \
'/usr/share/plinth/actions/* , /freedombox/actions/*\n' \ 'actions/actions, /freedombox/actions/actions\n' \
'Defaults!FREEDOMBOX_ACTION_DEV closefrom_override\n' \
'plinth ALL=(ALL:ALL) NOPASSWD:SETENV : FREEDOMBOX_ACTION_DEV\n' \
'fbx ALL=(ALL:ALL) NOPASSWD : ALL\n' 'fbx ALL=(ALL:ALL) NOPASSWD : ALL\n'
_runc(image_file, ['tee', '/etc/sudoers.d/01-freedombox-development'], _runc(image_file, ['tee', '/etc/sudoers.d/01-freedombox-development'],
input=sudo_config.encode(), stdout=subprocess.DEVNULL) input=sudo_config.encode(), stdout=subprocess.DEVNULL)
@ -696,7 +698,10 @@ def _setup(image_file, distribution):
return return
logger.info('In container: Disabling automatic updates temporarily') logger.info('In container: Disabling automatic updates temporarily')
_runc(image_file, ['/usr/share/plinth/actions/upgrades', 'disable-auto']) contents = 'APT::Periodic::Update-Package-Lists "0";\n' \
'APT::Periodic::Unattended-Upgrade "0";\n'
_runc(image_file, ['tee', '/etc/apt/apt.conf.d/20auto-upgrades'],
input=contents.encode())
logger.info('In container: Disabling FreedomBox service') logger.info('In container: Disabling FreedomBox service')
_runc(image_file, ['systemctl', 'disable', 'plinth'], _runc(image_file, ['systemctl', 'disable', 'plinth'],

View File

@ -7,9 +7,6 @@
ServerName $domain ServerName $domain
DocumentRoot /var/www/html DocumentRoot /var/www/html
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLEngine on SSLEngine on
# Disable TLS1.1 and below. Client support: Firefox: 27, Android: # Disable TLS1.1 and below. Client support: Firefox: 27, Android:

View File

@ -145,4 +145,7 @@ RedirectMatch "^/$" "/plinth"
## journalctl --identifier apache-error --output cat > error.log ## journalctl --identifier apache-error --output cat > error.log
## ##
ErrorLog "|/usr/bin/systemd-cat --identifier=apache-error" ErrorLog "|/usr/bin/systemd-cat --identifier=apache-error"
# Remove timestamp at the beginning from the default log format. journald
# records its own timestamp.
ErrorLogFormat "[%-m:%l] [pid %P:tid %{g}T] %7F: %E: [client\ %a] %M% ,\ referer\ %{Referer}i"
CustomLog "|/usr/bin/systemd-cat --identifier=apache-access" vhost_combined CustomLog "|/usr/bin/systemd-cat --identifier=apache-access" vhost_combined

View File

@ -2,7 +2,9 @@
# Allow plinth user to run plinth action scripts with superuser privileges # Allow plinth user to run plinth action scripts with superuser privileges
# without needing a password. # without needing a password.
# #
plinth ALL=(ALL:ALL) NOPASSWD:/usr/share/plinth/actions/* Cmnd_Alias FREEDOMBOX_ACTION = /usr/share/plinth/actions/actions
Defaults!FREEDOMBOX_ACTION closefrom_override
plinth ALL=(ALL:ALL) NOPASSWD:FREEDOMBOX_ACTION
# #
# On FreedomBox, allow all users in the 'admin' LDAP group to execute # On FreedomBox, allow all users in the 'admin' LDAP group to execute

111
debian/changelog vendored
View File

@ -1,3 +1,114 @@
freedombox (22.22) unstable; urgency=medium
[ Michael Breidenbach ]
* Translated using Weblate (Swedish)
[ Tymofii Lytvynenko ]
* Translated using Weblate (Ukrainian)
* Translated using Weblate (Ukrainian)
* Translated using Weblate (Ukrainian)
[ Jiří Podhorecký ]
* Translated using Weblate (Czech)
[ Sunil Mohan Adapa ]
* templates: Update HTML meta tags for better description and app-name
* doc: dev: Minor example code refactor
* actions: Allow nested and top-level actions
* actions: Use separate IPC for communicating results
* actions: Implement getting raw output from the process
* actions: Allow actions to be called by other users
* config: Drop ability to set hostname on systems without systemd
* dynamicdns: Check action script with flake8
* tests: Add fixture to help in testing privileged actions
* apache: Use privileged decorator for actions
* bepasty: Use privileged decorator for actions
* bind: Use privileged decorator for actions
* calibre: Use privileged decorator for actions
* config: Minor update to privileged method signature
* config: Use privileged decorator for actions
* config: Use privileged decorator for set-hostname action
* config: Use privileged decorator for set domainname action
* config: Minor refactor
* coturn: Use privileged decorator for actions
* datetime: Use privileged decorator for actions
* deluge: Use privileged decorator for actions
* dynamicdns: Use privileged decorator for actions
* ejabberd: Use privileged decorator for actions
* email: Use privileged decorator for actions
* firewall: Use privileged decorator, drop showing running status
* gitweb: Use privileged decorator for actions
* help: Use privileged decorator for actions
* i2p: Use privileged decorator for actions
* ikiwiki: Use privileged decorator for actions
* infinoted: Use privileged decorator for actions
* letsencrypt: Use privileged decorator for actions
* matrixsynapse: Use privileged decorator for actions
* mediawiki: Use privileged decorator for actions
* minetest: Use privileged decorator for actions
* minidlna: Use privileged decorator for actions
* minidlna: Use the exposed URL for diagnostic test
* networks: Use privileged decorator for actions
* openvpn: Use privileged decorator for actions
* openvpn: Drop RSA to ECC migration code and two-step setup
* pagekite: Use privileged decorator for actions
* power: Use privileged decorator for actions
* quassel: Use privileged decorator for actions
* radicale: Use privileged decorator for actions
* roundcube: Minor update to comment in privileged actions
* searx: Use privileged decorator for actions
* searx: Show status of public access irrespective of enabled state
* security: Use privileged decorator for actions
* shadowsocks: Use privileged decorator for actions
* sharing: Use privileged decorator for actions
* snapshot: Use privileged decorator for actions
* ssh: Use privileged decorator for actions
* sso: Use privileged decorator for actions
* syncthing: Use privileged decorator for actions
* tor: Use privileged decorator for actions
* transmission: Minor update to privileged method signature
* ttrss: Use privileged decorator for actions
* upgrades: Use privileged decorator for actions
* wireguard: Us privileged decorator for actions
* wordpress: Use privileged decorator for actions
* zoph: Use privileged decorator for actions
* backups: Use privileged decorator for sshfs actions
* samba: Use privileged decorator for actions
* storage: Use privileged decorator for actions
* users: Use privileged decorator for actions
* *: Use privileged decorator for service actions
* backups: Use privileged decorator for backup actions
* *: Use privileged decorator for package actions
* actions: Drop unused superuser_run and related methods
* action_utils: Drop unused progress requests from apt-get
* bind: Drop enabling DNSSEC (deprecated) as it is always enabled
* config: Drop legacy migration of Apache homepage settings
* action_utils: Drop support for non-systemd environments
* apache: Fix logs still going into /var/log files
* wordpress: Update fail2ban filter
* fail2ban: Make fail2ban log to journald
* privacy: Set vendor as FreedomBox for dpkg and popularity-contest
[ Petter Reinholdtsen ]
* Translated using Weblate (Norwegian Bokmål)
[ Besnik Bleta ]
* Translated using Weblate (Albanian)
* Translated using Weblate (Albanian)
[ nbenedek ]
* matrix: Add fail2ban jail
* privacy: Add new system app for popularity-contest
[ Nikita Epifanov ]
* Translated using Weblate (Russian)
[ James Valleroy ]
* locale: Update translation strings
* doc: Fetch latest manual
-- James Valleroy <jvalleroy@mailbox.org> Mon, 10 Oct 2022 21:38:11 -0400
freedombox (22.21.1~bpo11+1) bullseye-backports; urgency=medium freedombox (22.21.1~bpo11+1) bullseye-backports; urgency=medium
* Rebuild for bullseye-backports. * Rebuild for bullseye-backports.

View File

@ -21,3 +21,5 @@ rm_conffile /etc/plinth/modules-enabled/mldonkey 22.4~
rm_conffile /etc/apache2/conf-available/mldonkey-freedombox.conf 22.4~ rm_conffile /etc/apache2/conf-available/mldonkey-freedombox.conf 22.4~
rm_conffile /etc/apache2/sites-available/plinth.conf 22.16~ rm_conffile /etc/apache2/sites-available/plinth.conf 22.16~
rm_conffile /etc/apache2/sites-available/plinth-ssl.conf 22.16~ rm_conffile /etc/apache2/sites-available/plinth-ssl.conf 22.16~
rm_conffile /etc/fail2ban/jail.d/wordpress-auth-freedombox.conf 22.22~
rm_conffile /etc/fail2ban/filter.d/wordpress-auth-freedombox.conf 22.22~

View File

@ -13,4 +13,4 @@ else. These actions are also directly usable by a skilled administrator.
The following documentation for the ``actions`` module. The following documentation for the ``actions`` module.
.. automodule:: plinth.actions .. automodule:: plinth.actions
:members: run, superuser_run, run_as_user, _run, privileged :members: privileged

View File

@ -73,8 +73,7 @@ provide options to the user. Add the following to ``forms.py``.
def __init__(self, *args, **kw): def __init__(self, *args, **kw):
validator = DirectoryValidator(username=SYSTEM_USER, validator = DirectoryValidator(username=SYSTEM_USER,
check_creatable=True) check_creatable=True)
super(TransmissionForm, super().__init__(title=_('Download directory'),
self).__init__(title=_('Download directory'),
default='/var/lib/transmission-daemon/downloads', default='/var/lib/transmission-daemon/downloads',
validator=validator, *args, **kw) validator=validator, *args, **kw)

View File

@ -44,8 +44,8 @@ Use these hardware if you are able to download !FreedomBox images and prepare an
||Pine A64+ ||1.2x4||arm64/sunxi ||½,1,2||-||- || - ||1000 || {X} || ||Pine A64+ ||1.2x4||arm64/sunxi ||½,1,2||-||- || - ||1000 || {X} ||
||Banana Pro ||1.2x2||armhf/sunxi ||1||-||- || (./) ||1000 || {X} || ||Banana Pro ||1.2x2||armhf/sunxi ||1||-||- || (./) ||1000 || {X} ||
||Orange Pi Zero ||?x4 ||armhf/sunxi ||¼,½||-||- || - ||100 || {X} || ||Orange Pi Zero ||?x4 ||armhf/sunxi ||¼,½||-||- || - ||100 || {X} ||
||!RockPro64 ||1.4x4+1.8x2||arm64 ||2,4||16,32,64,128|| - || (./) ||1000 || {X} || ||!RockPro64 ||1.4x4+1.8x2||arm64 ||2,4||16,32,64,128|| - || (USB3 or [[https://wiki.pine64.org/wiki/ROCKPro64#SATA_Drives|via PCIe card]]) ||1000 || {X} ||
||Rock64 ||1.5x4||arm64 ||1,2,4||16,32,64,128|| - || (./) ||1000 || {X} || ||Rock64 ||1.5x4||arm64 ||1,2,4||16,32,64,128|| - || (USB3) ||1000 || {X} ||
== Additional Hardware == == Additional Hardware ==

View File

@ -8,6 +8,43 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
The following are the release notes for each !FreedomBox version. The following are the release notes for each !FreedomBox version.
== FreedomBox 22.22 (2022-10-10) ==
=== Highlights ===
* privacy: Add new system app for popularity-contest
* matrix: Add fail2ban jail
=== Other Changes ===
* *: Use privileged decorator for actions
* action_utils: Drop support for non-systemd environments
* action_utils: Drop unused progress requests from apt-get
* actions: Allow actions to be called by other users
* actions: Allow nested and top-level actions
* actions: Drop unused superuser_run and related methods
* actions: Implement getting raw output from the process
* actions: Use separate IPC for communicating results
* apache: Fix logs still going into /var/log files
* bind: Drop enabling DNSSEC (deprecated) as it is always enabled
* config: Drop ability to set hostname on systems without systemd
* config: Drop legacy migration of Apache homepage settings
* fail2ban: Make fail2ban log to journald
* firewall: Drop showing running status
* locale: Update translations for Albanian, Czech, Norwegian Bokmål, Russian, Swedish, Ukrainian
* minidlna: Use the exposed URL for diagnostic test
* openvpn: Drop RSA to ECC migration code and two-step setup
* privacy: Set vendor as !FreedomBox for dpkg and popularity-contest
* searx: Show status of public access irrespective of enabled state
* templates: Update HTML meta tags for better description and app-name
* tests: Add fixture to help in testing privileged actions
* wordpress: Update fail2ban filter
== FreedomBox 22.21.1 (2022-10-01) ==
* locale: Update translations for Bulgarian, Ukrainian
* notification: Don't fail when formatting message strings
== FreedomBox 22.21 (2022-09-26) == == FreedomBox 22.21 (2022-09-26) ==
* janus: Enable systemd sandboxing * janus: Enable systemd sandboxing

View File

@ -8,7 +8,7 @@
## BEGIN_INCLUDE ## BEGIN_INCLUDE
== Secure Shell (SSH) Sever == == Secure Shell (SSH) Server ==
=== What is Secure Shell? === === What is Secure Shell? ===

View File

@ -0,0 +1,34 @@
#language en
##TAG:TRANSLATION-HEADER-START
~- [[FreedomBox/Manual/Shaarli|English]] - [[DebianWiki/EditorGuide#translation|(+)]] -~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>
## BEGIN_INCLUDE
== Shaarli (Bookmarks) ==
||<tablestyle="float: right;"> {{attachment:Shaarli-icon_en_V01.png|Shaarli icon}} ||
'''Available since''': version 21.15
=== What is Shaarli? ===
Shaarli is personal (single-user) bookmarking application to install on your !FreedomBox. It can also be used for micro-blogging, pastebin, online notepad and snippet archive. Shaarli is designed as a no-database delicious clone. As such, it provides very fast services, easy backup and import/export links as desktop or mobile browser bookmarks. Links stored can be public or private. Shaarli delivers ATOM and RSS feeds from its minimalist interface.
{{attachment:Shaarli-screenshot_en_V01.png|Shaarli screenshot|width=800}}
=== External links ===
* Usage documentation: https://shaarli.readthedocs.io/en/master/Usage/
## END_INCLUDE
Back to [[FreedomBox/Features|Features introduction]] or [[FreedomBox/Manual|manual]] pages.
<<Include(FreedomBox/Portal)>>
----
CategoryFreedomBox

View File

@ -41,6 +41,7 @@
<<Include(FreedomBox/Manual/RSSBridge, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/RSSBridge, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Samba, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Samba, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Searx, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Searx, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Shaarli, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Shadowsocks, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Shadowsocks, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Sharing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Sharing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Syncthing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Syncthing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

@ -41,6 +41,7 @@
<<Include(FreedomBox/Manual/RSSBridge, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/RSSBridge, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Samba, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Samba, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Searx, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Searx, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Shaarli, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Shadowsocks, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Shadowsocks, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Sharing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Sharing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>
<<Include(FreedomBox/Manual/Syncthing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>> <<Include(FreedomBox/Manual/Syncthing, , from="## BEGIN_INCLUDE", to="## END_INCLUDE")>>

View File

@ -8,6 +8,43 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
The following are the release notes for each !FreedomBox version. The following are the release notes for each !FreedomBox version.
== FreedomBox 22.22 (2022-10-10) ==
=== Highlights ===
* privacy: Add new system app for popularity-contest
* matrix: Add fail2ban jail
=== Other Changes ===
* *: Use privileged decorator for actions
* action_utils: Drop support for non-systemd environments
* action_utils: Drop unused progress requests from apt-get
* actions: Allow actions to be called by other users
* actions: Allow nested and top-level actions
* actions: Drop unused superuser_run and related methods
* actions: Implement getting raw output from the process
* actions: Use separate IPC for communicating results
* apache: Fix logs still going into /var/log files
* bind: Drop enabling DNSSEC (deprecated) as it is always enabled
* config: Drop ability to set hostname on systems without systemd
* config: Drop legacy migration of Apache homepage settings
* fail2ban: Make fail2ban log to journald
* firewall: Drop showing running status
* locale: Update translations for Albanian, Czech, Norwegian Bokmål, Russian, Swedish, Ukrainian
* minidlna: Use the exposed URL for diagnostic test
* openvpn: Drop RSA to ECC migration code and two-step setup
* privacy: Set vendor as !FreedomBox for dpkg and popularity-contest
* searx: Show status of public access irrespective of enabled state
* templates: Update HTML meta tags for better description and app-name
* tests: Add fixture to help in testing privileged actions
* wordpress: Update fail2ban filter
== FreedomBox 22.21.1 (2022-10-01) ==
* locale: Update translations for Bulgarian, Ukrainian
* notification: Don't fail when formatting message strings
== FreedomBox 22.21 (2022-09-26) == == FreedomBox 22.21 (2022-09-26) ==
* janus: Enable systemd sandboxing * janus: Enable systemd sandboxing

View File

@ -3,4 +3,4 @@
Package init file. Package init file.
""" """
__version__ = '22.21.1' __version__ = '22.22'

View File

@ -39,13 +39,8 @@ def service_is_running(servicename):
Does not need to run as root. Does not need to run as root.
""" """
try: try:
if is_systemd_running():
subprocess.run(['systemctl', 'status', servicename], check=True, subprocess.run(['systemctl', 'status', servicename], check=True,
stdout=subprocess.DEVNULL) stdout=subprocess.DEVNULL)
else:
subprocess.run(['service', servicename, 'status'], check=True,
stdout=subprocess.DEVNULL)
return True return True
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
# If a service is not running we get a status code != 0 and # If a service is not running we get a status code != 0 and
@ -126,12 +121,8 @@ def service_reload(service_name):
def service_action(service_name, action): def service_action(service_name, action):
"""Perform the given action on the service_name.""" """Perform the given action on the service_name."""
if is_systemd_running():
subprocess.run(['systemctl', action, service_name], subprocess.run(['systemctl', action, service_name],
stdout=subprocess.DEVNULL, check=False) stdout=subprocess.DEVNULL, check=False)
else:
subprocess.run(['service', service_name, action],
stdout=subprocess.DEVNULL, check=False)
def webserver_is_enabled(name, kind='config'): def webserver_is_enabled(name, kind='config'):
@ -405,21 +396,12 @@ def is_disk_image():
def run_apt_command(arguments): def run_apt_command(arguments):
"""Run apt-get with provided arguments.""" """Run apt-get with provided arguments."""
# Ask apt-get to output its progress to file descriptor 3. command = ['apt-get', '--assume-yes', '--quiet=2'] + arguments
command = [
'apt-get', '--assume-yes', '--quiet=2', '--option', 'APT::Status-Fd=3'
] + arguments
# Duplicate stdout to file descriptor 3 for this process.
os.dup2(1, 3)
# Pass on file descriptor 3 instead of closing it. Close stdout
# so that regular output is ignored.
env = os.environ.copy() env = os.environ.copy()
env['DEBIAN_FRONTEND'] = 'noninteractive' env['DEBIAN_FRONTEND'] = 'noninteractive'
process = subprocess.run(command, stdin=subprocess.DEVNULL, process = subprocess.run(command, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, close_fds=False, stdout=subprocess.DEVNULL, env=env, check=False)
env=env, check=False)
return process.returncode return process.returncode

View File

@ -1,79 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Run specified actions. """Framework to run specified actions with elevated privileges."""
Actions run commands with this contract (version 1.1):
1. (promise) Super-user actions run as root. Normal actions do not.
2. (promise) The actions directory can't be changed at run time.
This guarantees that we can only select from the correct set of actions.
3. (restriction) Only specifically allowed actions can run.
A. Scripts in a directory above the actions directory can't be run.
Arguments (and options) can't coerce the system to run actions in
directories above the actions directory.
Arguments that fail this validation will raise a ValueError.
B. Scripts in a directory beneath the actions directory can't be run.
Arguments (and options) can't coerce the system to run actions in
sub-directories of the actions directory.
(An important side-effect of this is that the system will not try to
follow symlinks to other action directories.)
Arguments that fail this validation will raise a ValueError.
C. Only one action can be called at a time.
This prevents us from appending multiple (unexpected) actions to
the call. Any special shell characters in the action name will
simply be treated as the action itself when trying to search for
an action. The action will then not be found.
$ action="echo '$options'; echo 'oops'"
$ options="hi"
$ $action # oops, the file system is gone!
Arguments that fail this validation will raise a ValueError.
D. Options can't be used to run other actions:
$ action="echo '$options'"
$ options="hi'; rm -rf /;'"
$ $action # oops, the file system is gone!
Any option that tries to include special shell characters will
simply be treated as an option with special characters and will
not be interpreted by the shell.
Any call wishing to include special shell characters in options
list does not need to escape them. They are taken care of. The
option string is passed to the action exactly as it is passed in.
E. Actions must exist in the actions directory.
4. (promise) Options are passed as arguments to the action.
Options can be provided as a list or as a tuple.
5. (promise) Output is returned from the command. In case of an
error, ActionError is raised with action, output and error strings
as arguments.
6. (limitation) Providing the process with input is not possible.
Don't expect to give the process additional input after it's started. Any
interaction with the spawned process must be carried out through some other
method (maybe the process opens a socket, or something).
7. Option
"""
import functools import functools
import importlib import importlib
@ -81,168 +7,14 @@ import inspect
import json import json
import logging import logging
import os import os
import re
import shlex
import subprocess import subprocess
import sys import threading
from plinth import cfg from plinth import cfg
from plinth.errors import ActionError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run(action, options=None, input=None, run_in_background=False):
"""Safely run a specific action as the current user.
See actions._run for more information.
"""
return _run(action, options, input, run_in_background, False)
def superuser_run(action, options=None, input=None, run_in_background=False,
log_error=True):
"""Safely run a specific action as root.
See actions._run for more information.
"""
return _run(action, options, input, run_in_background, True,
log_error=log_error)
def run_as_user(action, options=None, input=None, run_in_background=False,
become_user=None):
"""Run a command as a different user.
If become_user is None, run as current user.
"""
return _run(action, options, input, run_in_background, False, become_user)
def _run(action, options=None, input=None, run_in_background=False,
run_as_root=False, become_user=None, log_error=True):
"""Safely run a specific action as a normal user or root.
Actions are pulled from the actions directory.
- options are added to the action command.
- input: data (as bytes) that will be sent to the action command's stdin.
- run_in_background: run asynchronously or wait for the command to
complete.
- run_as_root: execute the command through sudo.
"""
if options is None:
options = []
# Contract 3A and 3B: don't call anything outside of the actions directory.
if os.sep in action:
raise ValueError('Action cannot contain: ' + os.sep)
cmd = os.path.join(cfg.actions_dir, action)
if not os.path.realpath(cmd).startswith(cfg.actions_dir):
raise ValueError('Action has to be in directory %s' % cfg.actions_dir)
# Contract 3C: interpret shell escape sequences as literal file names.
# Contract 3E: fail if the action doesn't exist or exists elsewhere.
if not os.access(cmd, os.F_OK):
raise ValueError('Action must exist in action directory.')
cmd = [cmd]
# Contract: 3C, 3D: don't allow shell special characters in
# options be interpreted by the shell. When using
# subprocess.Popen with list invocation and not shell invocation,
# escaping is unnecessary as each argument is passed directly to
# the command and not parsed by a shell.
if options:
if not isinstance(options, (list, tuple)):
raise ValueError('Options must be list or tuple.')
cmd += list(options) # No escaping necessary
# Contract 1: commands can run via sudo.
sudo_call = []
if run_as_root:
sudo_call = ['sudo', '-n']
elif become_user:
sudo_call = ['sudo', '-n', '-u', become_user]
if cfg.develop and sudo_call:
# Passing 'env' does not work with sudo, so append the PYTHONPATH
# as part of the command
sudo_call += ['PYTHONPATH=%s' % cfg.file_root]
if sudo_call:
cmd = sudo_call + cmd
_log_command(cmd)
# Contract 3C: don't interpret shell escape sequences.
# Contract 5 (and 6-ish).
kwargs = {
'stdin': subprocess.PIPE,
'stdout': subprocess.PIPE,
'stderr': subprocess.PIPE,
'shell': False,
}
if cfg.develop:
# In development mode pass on local pythonpath to access Plinth
kwargs['env'] = {'PYTHONPATH': cfg.file_root}
proc = subprocess.Popen(cmd, **kwargs)
if not run_in_background:
output, error = proc.communicate(input=input)
output, error = output.decode(), error.decode()
if proc.returncode != 0:
if log_error:
logger.error('Error executing command - %s, %s, %s', cmd,
output, error)
raise ActionError(action, output, error)
return output
return proc
def _log_command(cmd):
"""Log a command with special pretty formatting to catch the eye."""
cmd = list(cmd) # Make a copy of the command not to affect the original
prompt = '$'
user = ''
if cmd and cmd[0] == 'sudo':
cmd = cmd[1:]
prompt = '#'
# Drop -n argument to sudo
if cmd and cmd[0] == '-n':
cmd = cmd[1:]
# Capture username separately
if len(cmd) > 1 and cmd[0] == '-u':
prompt = '$'
user = cmd[1]
cmd = cmd[2:]
# Drop environmental variables set via sudo
while cmd and re.match(r'.*=.*', cmd[0]):
cmd = cmd[1:]
# Strip the command's prefix
if cmd:
cmd[0] = cmd[0].split('/')[-1]
# Shell escape and join command arguments
cmd = ' '.join([shlex.quote(part) for part in cmd])
logger.info('%s%s %s', user, prompt, cmd)
def privileged(func): def privileged(func):
"""Mark a method as allowed to be run as privileged method. """Mark a method as allowed to be run as privileged method.
@ -282,18 +54,116 @@ def privileged(func):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
module_name = _get_privileged_action_module_name(func) module_name = _get_privileged_action_module_name(func)
action_name = func.__name__ action_name = func.__name__
return _run_privileged_method_as_process(module_name, action_name,
args, kwargs)
return wrapper
def _run_privileged_method_as_process(module_name, action_name, args, kwargs):
"""Execute the privileged method in a sub-process with sudo."""
run_as_user = kwargs.pop('_run_as_user', None)
run_in_background = kwargs.pop('_run_in_background', False)
raw_output = kwargs.pop('_raw_output', False)
log_error = kwargs.pop('_log_error', True)
read_fd, write_fd = os.pipe()
os.set_inheritable(write_fd, True)
# Prepare the command
command = ['sudo', '--non-interactive', '--close-from', str(write_fd + 1)]
if run_as_user:
command += ['--user', run_as_user]
if cfg.develop:
command += [f'PYTHONPATH={cfg.file_root}']
command += [
os.path.join(cfg.actions_dir, 'actions'), module_name, action_name,
'--write-fd',
str(write_fd)
]
proc_kwargs = {
'stdin': subprocess.PIPE,
'stdout': subprocess.PIPE,
'stderr': subprocess.PIPE,
'shell': False,
'pass_fds': [write_fd],
}
if cfg.develop:
# In development mode pass on local pythonpath to access Plinth
proc_kwargs['env'] = {'PYTHONPATH': cfg.file_root}
_log_action(module_name, action_name, run_as_user, run_in_background)
proc = subprocess.Popen(command, **proc_kwargs)
os.close(write_fd)
if raw_output:
input_ = json.dumps({'args': args, 'kwargs': kwargs}).encode()
return proc, read_fd, input_
buffers = []
# XXX: Use async to avoid creating a thread.
read_thread = threading.Thread(target=_thread_reader,
args=(read_fd, buffers))
read_thread.start()
wait_args = (module_name, action_name, args, kwargs, log_error, proc,
command, read_fd, read_thread, buffers)
if not run_in_background:
return _wait_for_return(*wait_args)
wait_thread = threading.Thread(target=_wait_for_return, args=wait_args)
wait_thread.start()
def _wait_for_return(module_name, action_name, args, kwargs, log_error, proc,
command, read_fd, read_thread, buffers):
"""Communicate with the subprocess and wait for its return."""
json_args = json.dumps({'args': args, 'kwargs': kwargs}) json_args = json.dumps({'args': args, 'kwargs': kwargs})
return_value = superuser_run('actions', [module_name, action_name],
input=json_args.encode()) output, error = proc.communicate(input=json_args.encode())
return_value = json.loads(return_value) read_thread.join()
if proc.returncode != 0:
logger.error('Error executing command - %s, %s, %s', command, output,
error)
raise subprocess.CalledProcessError(proc.returncode, command)
try:
return_value = json.loads(b''.join(buffers))
except json.JSONDecodeError:
logger.error(
'Error decoding action return value %s..%s(*%s, **%s): %s',
module_name, action_name, args, kwargs, return_value)
raise
if return_value['result'] == 'success': if return_value['result'] == 'success':
return return_value['return'] return return_value['return']
module = importlib.import_module(return_value['exception']['module']) module = importlib.import_module(return_value['exception']['module'])
exception = getattr(module, return_value['exception']['name']) exception_class = getattr(module, return_value['exception']['name'])
raise exception(*return_value['exception']['args']) exception = exception_class(*return_value['exception']['args'], output,
error)
if log_error:
logger.error('Error running action %s..%s(*%s, **%s): %s %s %s',
module_name, action_name, args, kwargs, exception,
exception.args, return_value['exception']['traceback'])
return wrapper raise exception
def _thread_reader(read_fd, buffers):
"""Read from the pipe in a separate thread."""
while True:
buffer = os.read(read_fd, 10240)
if not buffer:
break
buffers.append(buffer)
os.close(read_fd)
def _check_privileged_action_arguments(func): def _check_privileged_action_arguments(func):
@ -311,5 +181,20 @@ def _check_privileged_action_arguments(func):
def _get_privileged_action_module_name(func): def _get_privileged_action_module_name(func):
"""Figure out the module name of a privileged action.""" """Figure out the module name of a privileged action."""
module_name = func.__module__ module_name = func.__module__
module = sys.modules[module_name] while module_name:
return module.__package__.rpartition('.')[2] module_name, _, last = module_name.rpartition('.')
if last == 'privileged':
break
if not module_name:
raise ValueError('Privileged actions must be placed under a '
'package/module named privileged')
return module_name.rpartition('.')[2]
def _log_action(module_name, action_name, run_as_user, run_in_background):
"""Log an action in a compact format."""
prompt = f'({run_as_user})$' if run_as_user else '#'
suffix = '&' if run_in_background else ''
logger.info('%s %s..%s(…) %s', prompt, module_name, action_name, suffix)

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """Component for managing a background daemon or any systemd unit."""
Component for managing a background daemon or any systemd unit.
"""
import socket import socket
import subprocess import subprocess
@ -11,7 +9,7 @@ from django.utils.text import format_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
from plinth import action_utils, actions, app from plinth import action_utils, app
class Daemon(app.LeaderComponent): class Daemon(app.LeaderComponent):
@ -70,15 +68,17 @@ class Daemon(app.LeaderComponent):
def enable(self): def enable(self):
"""Run operations to enable the daemon/unit.""" """Run operations to enable the daemon/unit."""
actions.superuser_run('service', ['enable', self.unit]) from plinth.privileged import service as service_privileged
service_privileged.enable(self.unit)
if self.alias: if self.alias:
actions.superuser_run('service', ['enable', self.alias]) service_privileged.enable(self.alias)
def disable(self): def disable(self):
"""Run operations to disable the daemon/unit.""" """Run operations to disable the daemon/unit."""
actions.superuser_run('service', ['disable', self.unit]) from plinth.privileged import service as service_privileged
service_privileged.disable(self.unit)
if self.alias: if self.alias:
actions.superuser_run('service', ['disable', self.alias]) service_privileged.disable(self.alias)
def is_running(self): def is_running(self):
"""Return whether the daemon/unit is running.""" """Return whether the daemon/unit is running."""

View File

@ -8,10 +8,6 @@ class PlinthError(Exception):
"""Base class for all FreedomBox specific errors.""" """Base class for all FreedomBox specific errors."""
class ActionError(PlinthError):
"""Use this error for exceptions when executing an action."""
class PackageNotInstalledError(PlinthError): class PackageNotInstalledError(PlinthError):
"""Could not complete module setup due to missing package.""" """Could not complete module setup due to missing package."""

View File

@ -1,15 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """Manage application shortcuts on front page."""
Manage application shortcuts on front page.
"""
import json import json
import logging import logging
import pathlib import pathlib
from plinth import app, cfg from plinth import app, cfg
from plinth.modules.users import privileged as users_privileged
from . import actions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -116,8 +113,7 @@ class Shortcut(app.FollowerComponent):
return cls._all_shortcuts return cls._all_shortcuts
# XXX: Turn this into an API call in users module and cache # XXX: Turn this into an API call in users module and cache
output = actions.superuser_run('users', ['get-user-groups', username]) user_groups = set(users_privileged.get_user_groups(username))
user_groups = set(output.strip().split('\n'))
if 'admin' in user_groups: # Admin has access to all services if 'admin' in user_groups: # Admin has access to all services
return cls._all_shortcuts return cls._all_shortcuts

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """FreedomBox app for Apache server."""
FreedomBox app for Apache server.
"""
import os import os
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import actions
from plinth import app as app_module from plinth import app as app_module
from plinth import cfg from plinth import cfg
from plinth.daemon import Daemon, RelatedDaemon from plinth.daemon import Daemon, RelatedDaemon
@ -15,13 +13,15 @@ from plinth.modules.letsencrypt.components import LetsEncrypt
from plinth.package import Packages from plinth.package import Packages
from plinth.utils import format_lazy, is_valid_user_name from plinth.utils import format_lazy, is_valid_user_name
from . import privileged
class ApacheApp(app_module.App): class ApacheApp(app_module.App):
"""FreedomBox app for Apache web server.""" """FreedomBox app for Apache web server."""
app_id = 'apache' app_id = 'apache'
_version = 10 _version = 11
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
@ -60,9 +60,7 @@ class ApacheApp(app_module.App):
def setup(self, old_version): def setup(self, old_version):
"""Install and configure the app.""" """Install and configure the app."""
super().setup(old_version) super().setup(old_version)
actions.superuser_run('apache', privileged.setup(old_version)
['setup', '--old-version',
str(old_version)])
self.enable() self.enable()
@ -70,17 +68,17 @@ class ApacheApp(app_module.App):
def uws_directory_of_user(user): def uws_directory_of_user(user):
"""Returns the directory of the given user's website.""" """Return the directory of the given user's website."""
return '/home/{}/public_html'.format(user) return '/home/{}/public_html'.format(user)
def uws_url_of_user(user): def uws_url_of_user(user):
"""Returns the url path of the given user's website.""" """Return the url path of the given user's website."""
return '/~{}/'.format(user) return '/~{}/'.format(user)
def user_of_uws_directory(directory): def user_of_uws_directory(directory):
"""Returns the user of a given user website directory.""" """Return the user of a given user website directory."""
if directory.startswith('/home/'): if directory.startswith('/home/'):
pos_ini = 6 pos_ini = 6
elif directory.startswith('home/'): elif directory.startswith('home/'):
@ -97,7 +95,7 @@ def user_of_uws_directory(directory):
def user_of_uws_url(url): def user_of_uws_url(url):
"""Returns the user of a given user website url path.""" """Return the user of a given user website url path."""
MISSING = -1 MISSING = -1
pos_ini = url.find('~') pos_ini = url.find('~')
@ -113,7 +111,7 @@ def user_of_uws_url(url):
def uws_directory_of_url(url): def uws_directory_of_url(url):
"""Returns the directory of the user's website for the given url path. """Return the directory of the user's website for the given url path.
Note: It doesn't return the full OS file path to the url path! Note: It doesn't return the full OS file path to the url path!
""" """
@ -121,7 +119,7 @@ def uws_directory_of_url(url):
def uws_url_of_directory(directory): def uws_url_of_directory(directory):
"""Returns the url base path of the user's website for the given OS path. """Return the url base path of the user's website for the given OS path.
Note: It doesn't return the url path for the file! Note: It doesn't return the url path for the file!
""" """
@ -129,10 +127,10 @@ def uws_url_of_directory(directory):
def get_users_with_website(): def get_users_with_website():
"""Returns a dictionary of users with actual website subdirectory.""" """Return a dictionary of users with actual website subdirectory."""
def lst_sub_dirs(directory): def lst_sub_dirs(directory):
"""Returns the list of subdirectories of the given directory.""" """Return the list of subdirectories of the given directory."""
return [ return [
name for name in os.listdir(directory) name for name in os.listdir(directory)
if os.path.isdir(os.path.join(directory, name)) if os.path.isdir(os.path.join(directory, name))

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """App component for other apps to use Apache configuration functionality."""
App component for other apps to use Apache configuration functionality.
"""
import re import re
import subprocess import subprocess
@ -9,7 +7,9 @@ import subprocess
from django.utils.text import format_lazy from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
from plinth import action_utils, actions, app from plinth import action_utils, app
from . import privileged
class Webserver(app.LeaderComponent): class Webserver(app.LeaderComponent):
@ -47,14 +47,11 @@ class Webserver(app.LeaderComponent):
def enable(self): def enable(self):
"""Enable the Apache configuration.""" """Enable the Apache configuration."""
actions.superuser_run( privileged.enable(self.web_name, self.kind)
'apache', ['enable', '--name', self.web_name, '--kind', self.kind])
def disable(self): def disable(self):
"""Disable the Apache configuration.""" """Disable the Apache configuration."""
actions.superuser_run( privileged.disable(self.web_name, self.kind)
'apache',
['disable', '--name', self.web_name, '--kind', self.kind])
def diagnose(self): def diagnose(self):
"""Check if the web path is accessible by clients. """Check if the web path is accessible by clients.
@ -99,13 +96,11 @@ class Uwsgi(app.LeaderComponent):
def enable(self): def enable(self):
"""Enable the uWSGI configuration.""" """Enable the uWSGI configuration."""
actions.superuser_run('apache', privileged.uwsgi_enable(self.uwsgi_name)
['uwsgi-enable', '--name', self.uwsgi_name])
def disable(self): def disable(self):
"""Disable the uWSGI configuration.""" """Disable the uWSGI configuration."""
actions.superuser_run('apache', privileged.uwsgi_disable(self.uwsgi_name)
['uwsgi-disable', '--name', self.uwsgi_name])
def is_running(self): def is_running(self):
"""Return whether the uWSGI daemon is running with configuration.""" """Return whether the uWSGI daemon is running with configuration."""

View File

@ -1,3 +1,6 @@
[apache-auth] [apache-auth]
enabled = true enabled = true
backend = auto # Tweak the filter regex to work with journal format. Use apache-error as the
# syslog facility
filter = apache-auth[logtype="journal",logging="syslog",_daemon="apache-error"]
journalmatch = SYSLOG_IDENTIFIER=apache-error

88
actions/apache → plinth/modules/apache/privileged.py Executable file → Normal file
View File

@ -1,48 +1,13 @@
#!/usr/bin/python3
# -*- mode: python -*-
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """Configure Apache web server."""
Configuration helper for Apache web server.
"""
import argparse
import glob import glob
import os import os
import re import re
import subprocess import subprocess
from plinth import action_utils from plinth import action_utils
from plinth.actions import privileged
def parse_arguments():
"""Return parsed command line arguments as dictionary"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparser = subparsers.add_parser('setup', help='Setup for Apache')
subparser.add_argument(
'--old-version', type=int, required=True,
help='Earlier version of the app that is already setup.')
subparser = subparsers.add_parser(
'enable', help='Enable a site/config/module in apache')
subparser.add_argument('--name',
help='Name of the site/config/module to enable')
subparser.add_argument('--kind', choices=['site', 'config', 'module'])
subparser = subparsers.add_parser(
'disable', help='Disable a site/config/module in apache')
subparser.add_argument('--name',
help='Name of the site/config/module to disable')
subparser.add_argument('--kind', choices=['site', 'config', 'module'])
subparser = subparsers.add_parser(
'uwsgi-enable', help='Enable a site/config/module in UWSGI')
subparser.add_argument('--name',
help='Name of the site/config/module to enable')
subparser = subparsers.add_parser(
'uwsgi-disable', help='Disable a site/config/module in UWSGI')
subparser.add_argument('--name',
help='Name of the site/config/module to disable')
subparsers.required = True
return parser.parse_args()
def _get_sort_key_of_version(version): def _get_sort_key_of_version(version):
@ -87,14 +52,15 @@ def _disable_mod_php(webserver):
webserver.disable('php' + version, kind='module') webserver.disable('php' + version, kind='module')
def subcommand_setup(arguments): @privileged
def setup(old_version: int):
"""Setup Apache configuration.""" """Setup Apache configuration."""
# Regenerate the snakeoil self-signed SSL certificate. This is so that # Regenerate the snakeoil self-signed SSL certificate. This is so that
# FreedomBox images don't all have the same certificate. When FreedomBox # FreedomBox images don't all have the same certificate. When FreedomBox
# package is installed via apt, don't regenerate. When upgrading to newer # package is installed via apt, don't regenerate. When upgrading to newer
# version of Apache FreedomBox app and setting up for the first time don't # version of Apache FreedomBox app and setting up for the first time don't
# regenerate. # regenerate.
if action_utils.is_disk_image() and arguments.old_version == 0: if action_utils.is_disk_image() and old_version == 0:
subprocess.run([ subprocess.run([
'make-ssl-cert', 'generate-default-snakeoil', '--force-overwrite' 'make-ssl-cert', 'generate-default-snakeoil', '--force-overwrite'
], check=True) ], check=True)
@ -123,6 +89,9 @@ def subcommand_setup(arguments):
webserver.enable('rewrite', kind='module') webserver.enable('rewrite', kind='module')
webserver.enable('macro', kind='module') webserver.enable('macro', kind='module')
# Disable logging into files, use FreedomBox configured systemd logging
webserver.disable('other-vhosts-access-log', kind='config')
# Disable /server-status page to avoid leaking private info. # Disable /server-status page to avoid leaking private info.
webserver.disable('status', kind='module') webserver.disable('status', kind='module')
@ -178,34 +147,33 @@ def subcommand_setup(arguments):
# TODO: Check that the (name, kind) is a managed by FreedomBox before # TODO: Check that the (name, kind) is a managed by FreedomBox before
# performing operation. # performing operation.
def subcommand_enable(arguments): @privileged
def enable(name: str, kind: str):
"""Enable an Apache site/config/module.""" """Enable an Apache site/config/module."""
action_utils.webserver_enable(arguments.name, arguments.kind) _assert_kind(kind)
action_utils.webserver_enable(name, kind)
def subcommand_disable(arguments): @privileged
def disable(name: str, kind: str):
"""Disable an Apache site/config/module.""" """Disable an Apache site/config/module."""
action_utils.webserver_disable(arguments.name, arguments.kind) _assert_kind(kind)
action_utils.webserver_disable(name, kind)
def subcommand_uwsgi_enable(arguments): def _assert_kind(kind: str):
"""Raise and exception if kind parameter has an unexpected value."""
if kind not in ('site', 'config', 'module'):
raise ValueError('Invalid value for parameter kind')
@privileged
def uwsgi_enable(name: str):
"""Enable uWSGI configuration and reload.""" """Enable uWSGI configuration and reload."""
action_utils.uwsgi_enable(arguments.name) action_utils.uwsgi_enable(name)
def subcommand_uwsgi_disable(arguments): @privileged
def uwsgi_disable(name: str):
"""Disable uWSGI configuration and reload.""" """Disable uWSGI configuration and reload."""
action_utils.uwsgi_disable(arguments.name) action_utils.uwsgi_disable(name)
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -47,27 +47,22 @@ def test_webserver_is_enabled(webserver_is_enabled):
webserver_is_enabled.assert_has_calls([call('test-config', kind='module')]) webserver_is_enabled.assert_has_calls([call('test-config', kind='module')])
@patch('plinth.actions.superuser_run') @patch('plinth.modules.apache.privileged.enable')
def test_webserver_enable(superuser_run): def test_webserver_enable(enable):
"""Test that enabling webserver configuration works.""" """Test that enabling webserver configuration works."""
webserver = Webserver('test-webserver', 'test-config', kind='module') webserver = Webserver('test-webserver', 'test-config', kind='module')
webserver.enable() webserver.enable()
superuser_run.assert_has_calls([ enable.assert_has_calls([call('test-config', 'module')])
call('apache', ['enable', '--name', 'test-config', '--kind', 'module'])
])
@patch('plinth.actions.superuser_run') @patch('plinth.modules.apache.privileged.disable')
def test_webserver_disable(superuser_run): def test_webserver_disable(disable):
"""Test that disabling webserver configuration works.""" """Test that disabling webserver configuration works."""
webserver = Webserver('test-webserver', 'test-config', kind='module') webserver = Webserver('test-webserver', 'test-config', kind='module')
webserver.disable() webserver.disable()
superuser_run.assert_has_calls([ disable.assert_has_calls([call('test-config', 'module')])
call('apache',
['disable', '--name', 'test-config', '--kind', 'module'])
])
@patch('plinth.modules.apache.components.diagnose_url') @patch('plinth.modules.apache.components.diagnose_url')
@ -132,24 +127,22 @@ def test_uwsgi_is_enabled(uwsgi_is_enabled, service_is_enabled):
assert not uwsgi.is_enabled() assert not uwsgi.is_enabled()
@patch('plinth.actions.superuser_run') @patch('plinth.modules.apache.privileged.uwsgi_enable')
def test_uwsgi_enable(superuser_run): def test_uwsgi_enable(enable):
"""Test that enabling uwsgi configuration works.""" """Test that enabling uwsgi configuration works."""
uwsgi = Uwsgi('test-uwsgi', 'test-config') uwsgi = Uwsgi('test-uwsgi', 'test-config')
uwsgi.enable() uwsgi.enable()
superuser_run.assert_has_calls( enable.assert_has_calls([call('test-config')])
[call('apache', ['uwsgi-enable', '--name', 'test-config'])])
@patch('plinth.actions.superuser_run') @patch('plinth.modules.apache.privileged.uwsgi_disable')
def test_uwsgi_disable(superuser_run): def test_uwsgi_disable(disable):
"""Test that disabling uwsgi configuration works.""" """Test that disabling uwsgi configuration works."""
uwsgi = Uwsgi('test-uwsgi', 'test-config') uwsgi = Uwsgi('test-uwsgi', 'test-config')
uwsgi.disable() uwsgi.disable()
superuser_run.assert_has_calls( disable.assert_has_calls([call('test-config')])
[call('apache', ['uwsgi-disable', '--name', 'test-config'])])
@patch('plinth.action_utils.service_is_running') @patch('plinth.action_utils.service_is_running')

View File

@ -1,11 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """FreedomBox app for service discovery."""
FreedomBox app for service discovery.
"""
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import actions
from plinth import app as app_module from plinth import app as app_module
from plinth import cfg, menu from plinth import cfg, menu
from plinth.daemon import Daemon from plinth.daemon import Daemon
@ -14,6 +11,7 @@ from plinth.modules.config import get_hostname
from plinth.modules.firewall.components import Firewall from plinth.modules.firewall.components import Firewall
from plinth.modules.names.components import DomainType from plinth.modules.names.components import DomainType
from plinth.package import Packages from plinth.package import Packages
from plinth.privileged import service as service_privileged
from plinth.signals import domain_added, domain_removed, post_hostname_change from plinth.signals import domain_added, domain_removed, post_hostname_change
from plinth.utils import format_lazy from plinth.utils import format_lazy
@ -90,7 +88,7 @@ class AvahiApp(app_module.App):
# Reload avahi-daemon now that first-run does not reboot. After # Reload avahi-daemon now that first-run does not reboot. After
# performing FreedomBox Service (Plinth) package installation, new # performing FreedomBox Service (Plinth) package installation, new
# Avahi files will be available and require restart. # Avahi files will be available and require restart.
actions.superuser_run('service', ['reload', 'avahi-daemon']) service_privileged.reload('avahi-daemon')
self.enable() self.enable()

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """FreedomBox app to manage backup archives."""
FreedomBox app to manage backup archives.
"""
import json import json
import logging import logging
@ -14,12 +12,11 @@ from django.utils.text import get_valid_filename
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop from django.utils.translation import gettext_noop
from plinth import actions
from plinth import app as app_module from plinth import app as app_module
from plinth import cfg, glib, menu from plinth import cfg, glib, menu
from plinth.package import Packages from plinth.package import Packages
from . import api from . import api, privileged
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,7 +24,6 @@ _description = [
_('Backups allows creating and managing backup archives.'), _('Backups allows creating and managing backup archives.'),
] ]
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
# session variable name that stores when a backup file should be deleted # session variable name that stores when a backup file should be deleted
SESSION_PATH_VARIABLE = 'fbx-backups-upload-path' SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'
@ -69,8 +65,7 @@ class BackupsApp(app_module.App):
"""Install and configure the app.""" """Install and configure the app."""
super().setup(old_version) super().setup(old_version)
from . import repository from . import repository
actions.superuser_run( privileged.setup(repository.RootBorgRepository.PATH)
'backups', ['setup', '--path', repository.RootBorgRepository.PATH])
self.enable() self.enable()
# First time setup or upgrading from older versions. # First time setup or upgrading from older versions.
@ -79,11 +74,11 @@ class BackupsApp(app_module.App):
def _backup_handler(packet, encryption_passphrase=None): def _backup_handler(packet, encryption_passphrase=None):
"""Performs backup operation on packet.""" """Perform backup operation on packet."""
if not os.path.exists(MANIFESTS_FOLDER): if not os.path.exists(privileged.MANIFESTS_FOLDER):
os.makedirs(MANIFESTS_FOLDER) os.makedirs(privileged.MANIFESTS_FOLDER)
manifest_path = os.path.join(MANIFESTS_FOLDER, manifest_path = os.path.join(privileged.MANIFESTS_FOLDER,
get_valid_filename(packet.path) + '.json') get_valid_filename(packet.path) + '.json')
manifests = { manifests = {
'apps': [{ 'apps': [{
@ -97,17 +92,10 @@ def _backup_handler(packet, encryption_passphrase=None):
paths = packet.directories + packet.files paths = packet.directories + packet.files
paths.append(manifest_path) paths.append(manifest_path)
arguments = ['create-archive', '--path', packet.path]
if packet.archive_comment:
arguments += ['--comment', packet.archive_comment]
arguments += ['--paths'] + paths privileged.create_archive(packet.path, paths,
input_data = '' comment=packet.archive_comment,
if encryption_passphrase: encryption_passphrase=encryption_passphrase)
input_data = json.dumps(
{'encryption_passphrase': encryption_passphrase})
actions.superuser_run('backups', arguments, input=input_data.encode())
def backup_by_schedule(data): def backup_by_schedule(data):
@ -123,34 +111,16 @@ def backup_by_schedule(data):
exception=exception) exception=exception)
def get_exported_archive_apps(path):
"""Get list of apps included in exported archive file."""
arguments = ['get-exported-archive-apps', '--path', path]
output = actions.superuser_run('backups', arguments)
return output.splitlines()
def _restore_exported_archive_handler(packet, encryption_passphrase=None): def _restore_exported_archive_handler(packet, encryption_passphrase=None):
"""Perform restore operation on packet.""" """Perform restore operation on packet."""
locations = {'directories': packet.directories, 'files': packet.files} privileged.restore_exported_archive(packet.path, packet.directories,
locations_data = json.dumps(locations) packet.files)
actions.superuser_run('backups',
['restore-exported-archive', '--path', packet.path],
input=locations_data.encode())
def restore_archive_handler(packet, encryption_passphrase=None): def restore_archive_handler(packet, encryption_passphrase=None):
"""Perform restore operation on packet.""" """Perform restore operation on packet."""
locations = { privileged.restore_archive(packet.path, '/', packet.directories,
'directories': packet.directories, packet.files, encryption_passphrase)
'files': packet.files,
'encryption_passphrase': encryption_passphrase
}
locations_data = json.dumps(locations)
arguments = [
'restore-archive', '--path', packet.path, '--destination', '/'
]
actions.superuser_run('backups', arguments, input=locations_data.encode())
def restore_from_upload(path, app_ids=None): def restore_from_upload(path, app_ids=None):

View File

@ -12,9 +12,11 @@ TODO:
import logging import logging
from plinth import action_utils, actions from plinth import action_utils
from plinth import app as app_module from plinth import app as app_module
from plinth import setup from plinth import setup
from plinth.modules.apache import privileged as apache_privileged
from plinth.privileged import service as service_privileged
from .components import BackupRestore from .components import BackupRestore
@ -317,12 +319,12 @@ class SystemServiceHandler(ServiceHandler):
"""Stop the service.""" """Stop the service."""
self.was_running = action_utils.service_is_running(self.service) self.was_running = action_utils.service_is_running(self.service)
if self.was_running: if self.was_running:
actions.superuser_run('service', ['stop', self.service]) service_privileged.stop(self.service)
def restart(self): def restart(self):
"""Restart the service if it was earlier running.""" """Restart the service if it was earlier running."""
if self.was_running: if self.was_running:
actions.superuser_run('service', ['start', self.service]) service_privileged.start(self.service)
class ApacheServiceHandler(ServiceHandler): class ApacheServiceHandler(ServiceHandler):
@ -340,16 +342,12 @@ class ApacheServiceHandler(ServiceHandler):
self.was_enabled = action_utils.webserver_is_enabled( self.was_enabled = action_utils.webserver_is_enabled(
self.web_name, kind=self.kind) self.web_name, kind=self.kind)
if self.was_enabled: if self.was_enabled:
actions.superuser_run( apache_privileged.disable(self.web_name, self.kind)
'apache',
['disable', '--name', self.web_name, '--kind', self.kind])
def restart(self): def restart(self):
"""Restart the service if it was earlier running.""" """Restart the service if it was earlier running."""
if self.was_enabled: if self.was_enabled:
actions.superuser_run( apache_privileged.enable(self.web_name, self.kind)
'apache',
['enable', '--name', self.web_name, '--kind', self.kind])
def _shutdown_services(components): def _shutdown_services(components):

View File

@ -1,12 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """App component for other apps to use backup/restore functionality."""
App component for other apps to use backup/restore functionality.
"""
import copy import copy
import json
from plinth import actions, app from plinth import app
from . import privileged
def _validate_directories_and_files(section): def _validate_directories_and_files(section):
@ -150,19 +149,14 @@ class BackupRestore(app.FollowerComponent):
except Exception: except Exception:
pass pass
input_ = json.dumps(data).encode() privileged.dump_settings(self.app_id, data)
actions.superuser_run('backups',
['dump-settings', '--app-id', self.app_id],
input=input_)
def _settings_restore_post(self): def _settings_restore_post(self):
"""Read from a file and restore keys to kvstore.""" """Read from a file and restore keys to kvstore."""
if not self.settings: if not self.settings:
return return
output = actions.superuser_run( data = privileged.load_settings(self.app_id)
'backups', ['load-settings', '--app-id', self.app_id])
data = json.loads(output)
from plinth import kvstore from plinth import kvstore
for key, value in data.items(): for key, value in data.items():

View File

@ -0,0 +1,350 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure backups (with borg) and sshfs."""
import json
import os
import pathlib
import re
import subprocess
import tarfile
from typing import Optional, Union
from plinth.actions import privileged
from plinth.utils import Version
TIMEOUT = 30
BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/')
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
class AlreadyMountedError(Exception):
"""Exception raised when mount point is already mounted."""
@privileged
def mount(mountpoint: str, remote_path: str, ssh_keyfile: Optional[str] = None,
password: Optional[str] = None,
user_known_hosts_file: str = '/dev/null'):
"""Mount a remote ssh path via sshfs."""
try:
_validate_mountpoint(mountpoint)
except AlreadyMountedError:
return
kwargs = {}
# the shell would expand ~/ to the local home directory
remote_path = remote_path.replace('~/', '').replace('~', '')
# 'reconnect', 'ServerAliveInternal' and 'ServerAliveCountMax' allow the
# client (FreedomBox) to keep control of the SSH connection even when the
# SSH server misbehaves. Without these options, other commands such as
# '/usr/share/plinth/actions/actions storage usage_info --no-args', 'df',
# '/usr/share/plinth/actions/actions sshfs is_mounted --no-args', or
# 'mountpoint' might block indefinitely (even when manually invoked from
# the command line). This situation has some lateral effects, causing major
# system instability in the course of ~11 days, and leaving the system in
# such state that the only solution is a reboot.
cmd = [
'sshfs', remote_path, mountpoint, '-o',
f'UserKnownHostsFile={user_known_hosts_file}', '-o',
'StrictHostKeyChecking=yes', '-o', 'reconnect', '-o',
'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'
]
if ssh_keyfile:
cmd += ['-o', 'IdentityFile=' + ssh_keyfile]
else:
if not password:
raise ValueError('mount requires either a password or ssh_keyfile')
cmd += ['-o', 'password_stdin']
kwargs['input'] = password.encode()
subprocess.run(cmd, check=True, timeout=TIMEOUT, **kwargs)
@privileged
def umount(mountpoint: str):
"""Unmount a mountpoint."""
subprocess.run(['umount', mountpoint], check=True)
def _validate_mountpoint(mountpoint):
"""Check that the folder is empty, and create it if it doesn't exist."""
if os.path.exists(mountpoint):
if _is_mounted(mountpoint):
raise AlreadyMountedError('Mountpoint %s already mounted' %
mountpoint)
if os.listdir(mountpoint) or not os.path.isdir(mountpoint):
raise ValueError('Mountpoint %s is not an empty directory' %
mountpoint)
else:
os.makedirs(mountpoint)
def _is_mounted(mountpoint):
"""Return boolean whether a local directory is a mountpoint."""
cmd = ['mountpoint', '-q', mountpoint]
# mountpoint exits with status non-zero if it didn't find a mountpoint
try:
subprocess.run(cmd, check=True)
return True
except subprocess.CalledProcessError:
return False
@privileged
def is_mounted(mount_point: str) -> bool:
"""Return whether a path is already mounted."""
return _is_mounted(mount_point)
@privileged
def setup(path: str):
"""Create repository if it does not already exist."""
try:
_run(['borg', 'info', path], check=True)
except subprocess.CalledProcessError:
parent = os.path.dirname(path)
if not os.path.exists(parent):
os.makedirs(parent)
_init_repository(path, encryption='none')
def _init_repository(path: str, encryption: str,
encryption_passphrase: Optional[str] = None):
"""Initialize a local or remote borg repository."""
if encryption != 'none':
if not encryption_passphrase:
raise ValueError('No encryption passphrase provided')
cmd = ['borg', 'init', '--encryption', encryption, path]
_run(cmd, encryption_passphrase)
@privileged
def init(path: str, encryption: str,
encryption_passphrase: Optional[str] = None):
"""Initialize the borg repository."""
_init_repository(path, encryption, encryption_passphrase)
@privileged
def info(path: str, encryption_passphrase: Optional[str] = None) -> dict:
"""Show repository information."""
process = _run(['borg', 'info', '--json', path], encryption_passphrase,
stdout=subprocess.PIPE)
return json.loads(process.stdout.decode())
@privileged
def list_repo(path: str, encryption_passphrase: Optional[str] = None) -> dict:
"""List repository contents."""
process = _run(['borg', 'list', '--json', '--format="{comment}"', path],
encryption_passphrase, stdout=subprocess.PIPE)
return json.loads(process.stdout.decode())
def _get_borg_version():
"""Return the version of borgbackup."""
process = _run(['borg', '--version'], stdout=subprocess.PIPE)
return process.stdout.decode().split()[1] # Example: "borg 1.1.9"
@privileged
def create_archive(path: str, paths: list[str], comment: Optional[str] = None,
encryption_passphrase: Optional[str] = None):
"""Create archive."""
existing_paths = filter(os.path.exists, paths)
command = ['borg', 'create', '--json']
if comment:
if Version(_get_borg_version()) < Version('1.1.10'):
# Undo any placeholder escape sequences in comments as this version
# of borg does not support placeholders. XXX: Drop this code when
# support for borg < 1.1.10 is dropped.
comment = comment.replace('{{', '{').replace('}}', '}')
command += ['--comment', comment]
command += [path] + list(existing_paths)
_run(command, encryption_passphrase)
@privileged
def delete_archive(path: str, encryption_passphrase: Optional[str] = None):
"""Delete archive."""
_run(['borg', 'delete', path], encryption_passphrase)
def _extract(archive_path, destination, encryption_passphrase, locations=None):
"""Extract archive contents."""
prev_dir = os.getcwd()
borg_call = ['borg', 'extract', archive_path]
# do not extract any files when we get an empty locations list
if locations is not None:
borg_call.extend(locations)
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)
if process.returncode != 0:
error = process.stderr.decode()
# Don't fail on the borg error when no files were matched
if "never matched" not in error:
raise subprocess.CalledProcessError(process.returncode,
process.args)
finally:
os.chdir(prev_dir)
@privileged
def export_tar(path: str, encryption_passphrase: Optional[str] = None):
"""Export archive contents as tar stream on stdout."""
_run(['borg', 'export-tar', path, '-', '--tar-filter=gzip'],
encryption_passphrase)
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()
@privileged
def get_archive_apps(path: str,
encryption_passphrase: Optional[str] = None) -> list[str]:
"""Get list of apps included in archive."""
manifest_folder = os.path.relpath(MANIFESTS_FOLDER, '/')
borg_call = [
'borg', 'list', path, manifest_folder, '--format', '{path}{NEWLINE}'
]
try:
borg_process = _run(borg_call, encryption_passphrase,
stdout=subprocess.PIPE)
manifest_path = borg_process.stdout.decode().strip()
except subprocess.CalledProcessError:
raise RuntimeError('Borg exited unsuccessfully')
manifest = None
if manifest_path:
manifest_data = _read_archive_file(path, manifest_path,
encryption_passphrase)
manifest = json.loads(manifest_data)
archive_apps = []
if manifest:
for app in _get_apps_of_manifest(manifest):
archive_apps.append(app['name'])
return archive_apps
def _get_apps_of_manifest(manifest):
"""Get apps of a manifest.
Supports both dict format as well as list format of plinth <=0.42
"""
if isinstance(manifest, list):
apps = manifest
elif isinstance(manifest, dict) and 'apps' in manifest:
apps = manifest['apps']
else:
raise RuntimeError('Unknown manifest format')
return apps
@privileged
def get_exported_archive_apps(path: str) -> list[str]:
"""Get list of apps included in an exported archive file."""
manifest = None
with tarfile.open(path) as tar_handle:
filenames = tar_handle.getnames()
for name in filenames:
if 'var/lib/plinth/backups-manifests/' in name \
and name.endswith('.json'):
manifest_data = tar_handle.extractfile(name).read()
manifest = json.loads(manifest_data)
break
app_names = []
if manifest:
for app in _get_apps_of_manifest(manifest):
app_names.append(app['name'])
return app_names
@privileged
def restore_archive(archive_path: str, destination: str,
directories: list[str], files: list[str],
encryption_passphrase: Optional[str] = None):
"""Restore files from an archive."""
locations_all = directories + files
locations_all = [
os.path.relpath(location, '/') for location in locations_all
]
_extract(archive_path, destination, encryption_passphrase,
locations=locations_all)
@privileged
def restore_exported_archive(path: str, directories: list[str],
files: list[str]):
"""Restore files from an exported archive."""
with tarfile.open(path) as tar_handle:
for member in tar_handle.getmembers():
path = '/' + member.name
if path in files:
tar_handle.extract(member, '/')
else:
for directory in directories:
if path.startswith(directory):
tar_handle.extract(member, '/')
break
def _assert_app_id(app_id):
"""Check that app ID is correct."""
if not re.fullmatch(r'[a-z0-9_]+', app_id):
raise Exception('Invalid App ID')
@privileged
def dump_settings(app_id: str, settings: dict[str, Union[int, float, bool,
str]]):
"""Dump an app's settings to a JSON file."""
_assert_app_id(app_id)
BACKUPS_DATA_PATH.mkdir(exist_ok=True)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
settings_path.write_text(json.dumps(settings))
@privileged
def load_settings(app_id: str) -> dict[str, Union[int, float, bool, str]]:
"""Load an app's settings from a JSON file."""
_assert_app_id(app_id)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
try:
return json.loads(settings_path.read_text())
except FileNotFoundError:
return {}
def _get_env(encryption_passphrase: Optional[str] = None):
"""Create encryption and ssh kwargs out of given arguments."""
env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes',
LANG='C.UTF-8')
# Always provide BORG_PASSPHRASE (also if empty) so borg does not get stuck
# while asking for a passphrase.
env['BORG_PASSPHRASE'] = encryption_passphrase or ''
return env
def _run(cmd, encryption_passphrase=None, check=True, **kwargs):
"""Wrap the command with extra encryption passphrase handling."""
env = _get_env(encryption_passphrase)
return subprocess.run(cmd, check=check, env=env, **kwargs)

View File

@ -1,12 +1,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """Remote and local Borg backup repositories."""
Remote and local Borg backup repositories
"""
import abc import abc
import contextlib import contextlib
import io import io
import json
import logging import logging
import os import os
import re import re
@ -15,11 +12,10 @@ from uuid import uuid1
import paramiko import paramiko
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import actions, cfg from plinth import cfg
from plinth.errors import ActionError
from plinth.utils import format_lazy from plinth.utils import format_lazy
from . import (_backup_handler, api, errors, get_known_hosts_path, from . import (_backup_handler, api, errors, get_known_hosts_path, privileged,
restore_archive_handler, split_path, store) restore_archive_handler, split_path, store)
from .schedule import Schedule from .schedule import Schedule
@ -139,8 +135,10 @@ class BaseBorgRepository(abc.ABC):
def get_info(self): def get_info(self):
"""Return Borg information about a repository.""" """Return Borg information about a repository."""
output = self.run(['info', '--path', self.borg_path]) with self._handle_errors():
output = json.loads(output) output = privileged.info(self.borg_path,
self._get_encryption_passpharse())
if output['encryption']['mode'] == 'none' and \ if output['encryption']['mode'] == 'none' and \
self._get_encryption_data(): self._get_encryption_data():
raise errors.BorgUnencryptedRepository( raise errors.BorgUnencryptedRepository(
@ -149,7 +147,7 @@ class BaseBorgRepository(abc.ABC):
return output return output
def get_view_content(self): def get_view_content(self):
"""Get archives with additional information as needed by the view""" """Get archives with additional information as needed by the view."""
repository = { repository = {
'uuid': self.uuid, 'uuid': self.uuid,
'name': self.name, 'name': self.name,
@ -162,7 +160,7 @@ class BaseBorgRepository(abc.ABC):
repository['mounted'] = self.is_mounted repository['mounted'] = self.is_mounted
if repository['mounted']: if repository['mounted']:
repository['archives'] = self.list_archives() repository['archives'] = self.list_archives()
except (errors.BorgError, ActionError) as err: except (errors.BorgError, Exception) as err:
repository['error'] = str(err) repository['error'] = str(err)
return repository return repository
@ -172,8 +170,9 @@ class BaseBorgRepository(abc.ABC):
def list_archives(self): def list_archives(self):
"""Return list of archives in this repository.""" """Return list of archives in this repository."""
output = self.run(['list-repo', '--path', self.borg_path]) with self._handle_errors():
archives = json.loads(output)['archives'] archives = privileged.list_repo(
self.borg_path, self._get_encryption_passpharse())['archives']
return sorted(archives, key=lambda archive: archive['start'], return sorted(archives, key=lambda archive: archive['start'],
reverse=True) reverse=True)
@ -188,7 +187,9 @@ class BaseBorgRepository(abc.ABC):
def delete_archive(self, archive_name): def delete_archive(self, archive_name):
"""Delete an archive with given name from this repository.""" """Delete an archive with given name from this repository."""
archive_path = self._get_archive_path(archive_name) archive_path = self._get_archive_path(archive_name)
self.run(['delete-archive', '--path', archive_path]) with self._handle_errors():
privileged.delete_archive(archive_path,
self._get_encryption_passpharse())
def initialize(self): def initialize(self):
"""Initialize / create a borg repository.""" """Initialize / create a borg repository."""
@ -198,8 +199,9 @@ class BaseBorgRepository(abc.ABC):
encryption = 'repokey' encryption = 'repokey'
try: try:
self.run( with self._handle_errors():
['init', '--path', self.borg_path, '--encryption', encryption]) privileged.init(self.borg_path, encryption,
self._get_encryption_passpharse())
except errors.BorgRepositoryExists: except errors.BorgRepositoryExists:
pass pass
@ -213,25 +215,21 @@ class BaseBorgRepository(abc.ABC):
return {} return {}
def _run(self, cmd, arguments, superuser=True, **kwargs): @contextlib.contextmanager
"""Run a backups or sshfs action script command.""" def _handle_errors(self):
"""Parse exceptions into more specific ones."""
try: try:
if superuser: yield
return actions.superuser_run(cmd, arguments, **kwargs) except Exception as exception:
self.reraise_known_error(exception)
return actions.run(cmd, arguments, **kwargs) def _get_encryption_passpharse(self):
except ActionError as err: """Return encryption passphrase or raise an exception."""
self.reraise_known_error(err)
def run(self, arguments, superuser=True):
"""Add credentials and run a backups action script command."""
for key in self.credentials.keys(): for key in self.credentials.keys():
if key not in self.known_credentials: if key not in self.known_credentials:
raise ValueError('Unknown credentials entry: %s' % key) raise ValueError('Unknown credentials entry: %s' % key)
input_data = json.dumps(self._get_encryption_data()) return self.credentials.get('encryption_passphrase', None)
return self._run('backups', arguments, superuser=superuser,
input=input_data.encode())
def get_download_stream(self, archive_name): def get_download_stream(self, archive_name):
"""Return an stream of .tar.gz binary data for a backup archive.""" """Return an stream of .tar.gz binary data for a backup archive."""
@ -258,11 +256,16 @@ class BaseBorgRepository(abc.ABC):
return chunk return chunk
args = ['export-tar', '--path', self._get_archive_path(archive_name)] with self._handle_errors():
input_data = json.dumps(self._get_encryption_data()) proc, read_fd, input_ = privileged.export_tar(
proc = self._run('backups', args, run_in_background=True) self._get_archive_path(archive_name),
proc.stdin.write(input_data.encode()) self._get_encryption_passpharse(), _raw_output=True)
os.close(read_fd) # Don't use the pipe for communication, just stdout
proc.stdin.write(input_)
proc.stdin.close() proc.stdin.close()
proc.stderr.close() # writing to stderr in child will cause SIGPIPE
return BufferedReader(proc.stdout) return BufferedReader(proc.stdout)
def _get_archive_path(self, archive_name): def _get_archive_path(self, archive_name):
@ -272,7 +275,7 @@ class BaseBorgRepository(abc.ABC):
@staticmethod @staticmethod
def reraise_known_error(err): def reraise_known_error(err):
"""Look whether the caught error is known and reraise it accordingly""" """Look whether the caught error is known and reraise it accordingly"""
caught_error = str(err) caught_error = str((err, err.args))
for known_error in KNOWN_ERRORS: for known_error in KNOWN_ERRORS:
for error in known_error['errors']: for error in known_error['errors']:
if re.search(error, caught_error): if re.search(error, caught_error):
@ -291,8 +294,9 @@ class BaseBorgRepository(abc.ABC):
def get_archive_apps(self, archive_name): def get_archive_apps(self, archive_name):
"""Get list of apps included in an archive.""" """Get list of apps included in an archive."""
archive_path = self._get_archive_path(archive_name) archive_path = self._get_archive_path(archive_name)
output = self.run(['get-archive-apps', '--path', archive_path]) with self._handle_errors():
return output.splitlines() return privileged.get_archive_apps(
archive_path, self._get_encryption_passpharse())
def restore_archive(self, archive_name, app_ids=None): def restore_archive(self, archive_name, app_ids=None):
"""Restore an archive from this repository to the system.""" """Restore an archive from this repository to the system."""
@ -418,9 +422,8 @@ class SshBorgRepository(BaseBorgRepository):
@property @property
def is_mounted(self): def is_mounted(self):
"""Return whether remote path is mounted locally.""" """Return whether remote path is mounted locally."""
output = self._run('sshfs', with self._handle_errors():
['is-mounted', '--mountpoint', self._mountpoint]) return privileged.is_mounted(self._mountpoint)
return json.loads(output)
def initialize(self): def initialize(self):
"""Initialize the repository after mounting the target directory.""" """Initialize the repository after mounting the target directory."""
@ -432,22 +435,27 @@ class SshBorgRepository(BaseBorgRepository):
"""Mount the remote path locally using sshfs.""" """Mount the remote path locally using sshfs."""
if self.is_mounted: if self.is_mounted:
return return
known_hosts_path = get_known_hosts_path() known_hosts_path = get_known_hosts_path()
arguments = [ kwargs = {'user_known_hosts_file': str(known_hosts_path)}
'mount', '--mountpoint', self._mountpoint, '--path', self._path, if 'ssh_password' in self.credentials and self.credentials[
'--user-known-hosts-file', 'ssh_password']:
str(known_hosts_path) kwargs['password'] = self.credentials['ssh_password']
]
arguments, kwargs = self._append_sshfs_arguments( if 'ssh_keyfile' in self.credentials and self.credentials[
arguments, self.credentials) 'ssh_keyfile']:
self._run('sshfs', arguments, **kwargs) kwargs['ssh_keyfile'] = self.credentials['ssh_keyfile']
with self._handle_errors():
privileged.mount(self._mountpoint, self._path, **kwargs)
def umount(self): def umount(self):
"""Unmount the remote path that was mounted locally using sshfs.""" """Unmount the remote path that was mounted locally using sshfs."""
if not self.is_mounted: if not self.is_mounted:
return return
self._run('sshfs', ['umount', '--mountpoint', self._mountpoint]) with self._handle_errors():
privileged.umount(self._mountpoint)
def _umount_ignore_errors(self): def _umount_ignore_errors(self):
"""Run unmount operation and ignore any exceptions thrown.""" """Run unmount operation and ignore any exceptions thrown."""
@ -457,33 +465,20 @@ class SshBorgRepository(BaseBorgRepository):
logger.warning('Unable to unmount repository', exc_info=exception) logger.warning('Unable to unmount repository', exc_info=exception)
def remove(self): def remove(self):
"""Remove a repository from the kvstore and delete its mountpoint""" """Remove a repository from the kvstore and delete its mountpoint."""
self.umount() self.umount()
store.delete(self.uuid) store.delete(self.uuid)
try: try:
if os.path.exists(self._mountpoint): if os.path.exists(self._mountpoint):
try: try:
self.umount() self.umount()
except ActionError: except Exception:
pass pass
if not os.listdir(self._mountpoint): if not os.listdir(self._mountpoint):
os.rmdir(self._mountpoint) os.rmdir(self._mountpoint)
except Exception as err: except Exception as err:
logger.error(err) logger.error(err)
@staticmethod
def _append_sshfs_arguments(arguments, credentials):
"""Add credentials to a run command and kwargs"""
kwargs = {}
if 'ssh_password' in credentials and credentials['ssh_password']:
kwargs['input'] = credentials['ssh_password'].encode()
if 'ssh_keyfile' in credentials and credentials['ssh_keyfile']:
arguments += ['--ssh-keyfile', credentials['ssh_keyfile']]
return (arguments, kwargs)
def _ensure_remote_directory(self): def _ensure_remote_directory(self):
"""Create remote SSH directory if it does not exist.""" """Create remote SSH directory if it does not exist."""
username, hostname, dir_path = split_path(self.path) username, hostname, dir_path = split_path(self.path)

Some files were not shown because too many files have changed in this diff Show More