mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
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:
commit
df2fb42536
@ -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
2
Vagrantfile
vendored
@ -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
|
||||||
|
|||||||
@ -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__)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
349
actions/backups
349
actions/backups
@ -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()
|
|
||||||
62
actions/bind
62
actions/bind
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
38
actions/help
38
actions/help
@ -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()
|
|
||||||
@ -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
|
|
||||||
82
actions/i2p
82
actions/i2p
@ -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()
|
|
||||||
147
actions/ikiwiki
147
actions/ikiwiki
@ -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()
|
|
||||||
@ -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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
161
actions/networks
161
actions/networks
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
130
actions/service
130
actions/service
@ -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()
|
|
||||||
208
actions/sharing
208
actions/sharing
@ -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()
|
|
||||||
137
actions/sshfs
137
actions/sshfs
@ -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()
|
|
||||||
@ -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()
|
|
||||||
43
conftest.py
43
conftest.py
@ -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')
|
||||||
|
|||||||
13
container
13
container
@ -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'],
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
111
debian/changelog
vendored
@ -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.
|
||||||
|
|||||||
2
debian/freedombox.maintscript
vendored
2
debian/freedombox.maintscript
vendored
@ -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~
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 ==
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## BEGIN_INCLUDE
|
## BEGIN_INCLUDE
|
||||||
|
|
||||||
== Secure Shell (SSH) Sever ==
|
== Secure Shell (SSH) Server ==
|
||||||
|
|
||||||
=== What is Secure Shell? ===
|
=== What is Secure Shell? ===
|
||||||
|
|
||||||
|
|||||||
34
doc/manual/en/Shaarli.raw.wiki
Normal file
34
doc/manual/en/Shaarli.raw.wiki
Normal 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
|
||||||
@ -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")>>
|
||||||
|
|||||||
BIN
doc/manual/en/images/Shaarli-icon_en_V01.png
Normal file
BIN
doc/manual/en/images/Shaarli-icon_en_V01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
doc/manual/en/images/Shaarli-screenshot_en_V01.png
Normal file
BIN
doc/manual/en/images/Shaarli-screenshot_en_V01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@ -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")>>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -3,4 +3,4 @@
|
|||||||
Package init file.
|
Package init file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = '22.21.1'
|
__version__ = '22.22'
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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))
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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
88
actions/apache → plinth/modules/apache/privileged.py
Executable file → Normal 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()
|
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
350
plinth/modules/backups/privileged.py
Normal file
350
plinth/modules/backups/privileged.py
Normal 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)
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user