freedombox Debian release 24.26.1

-----BEGIN PGP SIGNATURE-----
 
 iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmd6xmAWHGp2YWxsZXJv
 eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICAjSEADUIDUnqu6/HKryq8KrYgOYmi05
 i1aye65HSzrr+1QyUKenB1lQ2ttgqF/5VbdNoN89W67GZvTnYw22d40C1Dx1wXtk
 rjDRSQFwP3LQE9eFr9GxlyNeSq5r2gPa76a2eoj6Hxz4E2XQxvDW8aK/BTCDRM5N
 lmVyxZUb+p49HMoJMJUx/uBpmrur+usZBPDM+q3pr0E+PuXj6oL/qzt4g/H0JkMs
 A72+G8Lcq8EQJHBstxdMLMl+f6+tuzy0NgVLdAgd7SNpfIjteD+jG7cUUq8bpKcm
 b7IvgKSy4Ze66yYsZkwAZy42LXfTAitUvGPdF0URBt6peoE4RVPFu9wNRtwOVIw3
 sowoTf038EG65q8LuqTkrmUSovN/uBcermzZ/MHnRxHX5RLS6ELVn42cEza/t+RF
 AgXnaUgG7fPXeiNU6AD4vQEAcmYtnQB7IHdXwiGC081CrilxWNbjWhPk/dC7lz2a
 qreMn9HiKjkQ2yN5C8GJZ7m2XO+HzwV2t9fTh4hIfNp05/Q9FAFkls30UHzClLxF
 JNV74pwBqLX3m7DXOfz5e8jjecAizN7n7hJQvVIWJRKf/Dmji/aqxZ2zV2HFzNco
 VJpYxvde0PbjGMPQopK7v0+f16D1/cHmytFtAD/P8YdbRlzMQtoJkFEXurzTaDz7
 NmsoedzbaIAra1ZZIA==
 =xzy5
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmeBoj8WHGp2YWxsZXJv
 eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICFgLEACYRuJbxtQ1GpO71co7fAYlMQKA
 rVke5Y0BoUqznfhBgcMHEu3nSNjIulgFR91rdbCw/WnrE9ON99rm4IXVPKuesbVv
 wMSz9Ez3U+i3mpUjl18tCgOgaOcapemQr00AX6gwsMqpunxp9A5vOcXrDPLUhrx1
 gg1OTt/ya6O/X+oVvZqRisYngRkx/LSKK4HJ5SjznknmIGZLn31sIvwCUx4dkt7f
 RFYIoBJd2NAcQ8xIoJp296jIsTZbz7eearKUSq4PfudmKrf+iEd7Tp/LiH32PIUL
 M5Frje7dTH2EgvO4nm4A4kB6wT3DymGCGHg/fFIVYeuuvG/fUdXdV/83FeLzZ7xE
 U2aW5ZeOBE8Wcn4gy/TrSDFkVVsdbK3VWUCDH7sidnB4X8jCOY2lWCBjyckMkGjf
 dp2WACfjJrzqpQtJF2Osu38qbmHy/EBv67cKZoFIRDXdd3feJv84vzSnYLG6SQLT
 YFpFyEDyHBWNNwmYi//7Lk67IB/NS2nWEms5aAX6X7YLqYZ/DGYfBzsi0rEwBF9X
 Xi+dqZzSFwwLMbraVHjMs8N8w9juaFzhm9TD9gRo+L4AZGudARWjF6hpL80A6jPK
 8zGP2aFIGMaOODDwoBb5mNcN0GNLUCbQrI1P60UH1NFo0XqDuMPrqODSwIL/WnzM
 M5GyCqM4ixgf1Qq5yg==
 =G0IP
 -----END PGP SIGNATURE-----

Merge tag 'v24.26.1' into debian/bookworm-backports

freedombox Debian release 24.26.1

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2025-01-10 17:42:05 -05:00
commit ec0ba5df3c
198 changed files with 29358 additions and 19491 deletions

View File

@ -19,7 +19,8 @@ DISABLED_APPS_TO_REMOVE := \
restore \ restore \
repro \ repro \
tahoe \ tahoe \
mldonkey mldonkey \
i2p
APP_FILES_TO_REMOVE := $(foreach app,$(DISABLED_APPS_TO_REMOVE),$(ENABLED_APPS_PATH)/$(app)) APP_FILES_TO_REMOVE := $(foreach app,$(DISABLED_APPS_TO_REMOVE),$(ENABLED_APPS_PATH)/$(app))

1177
container

File diff suppressed because it is too large Load Diff

129
debian/changelog vendored
View File

@ -1,3 +1,132 @@
freedombox (24.26.1) unstable; urgency=medium
[ Ettore Atalan ]
* Translated using Weblate (German)
[ Burak Yavuz ]
* Translated using Weblate (Turkish)
[ 大王叫我来巡山 ]
* Translated using Weblate (Chinese (Simplified Han script))
[ 109247019824 ]
* Translated using Weblate (Bulgarian)
* Translated using Weblate (Bulgarian)
* Translated using Weblate (Bulgarian)
[ Besnik Bleta ]
* Translated using Weblate (Albanian)
[ Benedek Nagy ]
* nextcloud: remove experimental warning
* email: Fix DKIM signing by setting correct ownership on private keys
[ Jiří Podhorecký ]
* Translated using Weblate (Czech)
[ James Valleroy ]
* mumble: Support config file moved into /etc/mumble
* mumble: Add diagnostic for setup config changes
[ Sunil Mohan Adapa ]
* sharing: Drop jQuery code as the library dependency has been removed
* users: Drop jQuery code as the library dependency has been removed
[ Coucouf ]
* Translated using Weblate (French)
-- James Valleroy <jvalleroy@mailbox.org> Sun, 05 Jan 2025 12:17:03 -0500
freedombox (24.26) unstable; urgency=medium
[ Sunil Mohan Adapa ]
* tests: functional: Make first wizard run more robust
* Makefile: Add i2p to list of apps to remove
* container: Refactor nspawn specific operations into a separate class
* container: Update FSID inside the image file to keep it bootable
* container: Minor refactoring to reduce repeated code
* container: Generalize language in output messages for VMs
* container: Add support for VMs using libvirt
* menu: Implement a helper method to lookup menu items using URL name
* views: Implement retrieving breadcrumbs of a page
* context_processors: Use breadcrumbs to highlight current section
* menu: Ensure that all menu items have names for use by breadcrumbs
* ui: Show breadcrumbs on deeper pages
* ui: Don't show breadcrumbs in login and first wizard pages
* views: Show exception details with the utility to show errors
* ui: Handle and show most page load errors as alerts
* middleware: Handle method not allowed errors and redirect
* middleware: Handle page not found errors specially
* diagnostics: Use generic handler to handle exceptions in diagnostics
* backups: Fix issue with verifying remote server identity
* backups: Fix issue with verifying SSH hosts with RSA key
* backups: Fix issue clicking on schedule buttons with Bootstrap 5
* system: Add tags to all remaining apps
* actions: Allow privileged methods to be decorated again
* backups: Parse borg errors from all operations and not just some
* backups: Require POST method for mount/unmount operations
* backups: Format better when showing archive time delete page
* backups: Use ISO timestamp for auto-naming archives
* backups: Handle common errors during borg operations
* backups: tests: functional: Wait for pages to load after click
* ui: Fix regression with margin above app title
* networks: Fix error during creation of PPPoE connections
[ Burak Yavuz ]
* Translated using Weblate (Turkish)
[ 109247019824 ]
* Translated using Weblate (Bulgarian)
[ Besnik Bleta ]
* Translated using Weblate (Albanian)
[ Ettore Atalan ]
* Translated using Weblate (German)
[ 大王叫我来巡山 ]
* Translated using Weblate (Chinese (Simplified Han script))
[ Joseph Nuthalapati ]
* Translated using Weblate (Telugu)
* tags: Add button to clear all tags
* ui: Replace use of jQuery with plain JavaScript
* debian: Remove dependency libjs-jquery
* tags: Replace short description with tags in app pages
* apps: Replace short description with tags in apps list
* zoph: Include tags from the manifest
* frontpage: Replace short description with tags
* tags: Add tags to system apps
* tags: Remove short description from system apps
[ Jiří Podhorecký ]
* Translated using Weblate (Czech)
[ தமிழ்நேரம் ]
* Translated using Weblate (Tamil)
[ James Valleroy ]
* Translated using Weblate (Tamil)
* minetest: Provide default gameid argument
* torproxy: Don't disable apt-transport-tor in setup
* backups: Remove unused import contextlib
* locale: Update translation strings
* doc: Fetch latest manual
[ Veiko Aasa ]
* tor, torproxy: Fix daemon services are running after reboot when app is
disabled
* tests: functional: Add utility to click element wait for page update
* samba: tests: functional: Wait for page update after enable/disable share
* sharing: tests: functional: Use click function from functional library
* mediawiki: tests: functional: Use click function from functional library
* miniflux: tests: functional: Use helper functions from functional library
* users: tests: functional: Use click function from functional library
* users: Restart nslcd service after configuration changes during setup
* tests: functional: Fix typos in diagnostics checks
-- James Valleroy <jvalleroy@mailbox.org> Mon, 30 Dec 2024 20:35:49 -0500
freedombox (24.25~bpo12+1) bookworm-backports; urgency=medium freedombox (24.25~bpo12+1) bookworm-backports; urgency=medium
* Rebuild for bookworm-backports. * Rebuild for bookworm-backports.

3
debian/control vendored
View File

@ -38,7 +38,6 @@ Build-Depends:
python3-markupsafe, python3-markupsafe,
python3-mypy, python3-mypy,
python3-pampy, python3-pampy,
python3-paramiko,
python3-pexpect, python3-pexpect,
python3-pip, python3-pip,
python3-psutil, python3-psutil,
@ -92,7 +91,6 @@ Depends:
# For gdbus used to call hooks into service # For gdbus used to call hooks into service
libglib2.0-bin, libglib2.0-bin,
libjs-bootstrap5, libjs-bootstrap5,
libjs-jquery,
lsof, lsof,
netcat-openbsd, netcat-openbsd,
network-manager, network-manager,
@ -114,7 +112,6 @@ Depends:
python3-gi, python3-gi,
python3-markupsafe, python3-markupsafe,
python3-pampy, python3-pampy,
python3-paramiko,
python3-pexpect, python3-pexpect,
python3-psutil, python3-psutil,
python3-requests, python3-requests,

View File

@ -211,7 +211,6 @@ autodoc_mock_imports = [
'gi', 'gi',
'markupsafe', 'markupsafe',
'pam', 'pam',
'paramiko',
'psutil', 'psutil',
'pytest', 'pytest',
'requests', 'requests',

View File

@ -82,7 +82,7 @@ sub rsa4096 2022-03-09 [E]
}}} }}}
* Finally, verify your downloaded image with its signature file `.sig`. For example: * Finally, verify your downloaded image with its signature file `.sig`. For example:
{{{ {{{
$ $ gpg --verify freedombox-bookworm_all-amd64.img.xz.sig $ gpg --verify freedombox-bookworm_all-amd64.img.xz.sig
gpg: assuming signed data in 'freedombox-bookworm_all-amd64.img.xz' gpg: assuming signed data in 'freedombox-bookworm_all-amd64.img.xz'
gpg: Signature made Wed 14 Jun 2023 03:22:04 PM PDT gpg: Signature made Wed 14 Jun 2023 03:22:04 PM PDT
gpg: using RSA key D4B069124FCF43AA1FCD7FBC2ACFC1E15AF82D8C gpg: using RSA key D4B069124FCF43AA1FCD7FBC2ACFC1E15AF82D8C

View File

@ -8,6 +8,82 @@ 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 24.26.1 (2025-01-05) ==
=== Highlights ===
* email: Fix DKIM signing by setting correct ownership on private keys
* nextcloud: remove experimental warning
=== Other Changes ===
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, French, German, Turkish
* mumble: Add diagnostic for setup config changes
* mumble: Support config file moved into /etc/mumble
* sharing: Drop jQuery code as the library dependency has been removed
* users: Drop jQuery code as the library dependency has been removed
== FreedomBox 24.26 (2024-12-30) ==
=== Highlights ===
* apps: Replace short description with tags in apps list
* ui: Show breadcrumbs on deeper pages
* ui: Handle and show most page load errors as alerts
=== Other Changes ===
* actions: Allow privileged methods to be decorated again
* backups: Fix issue clicking on schedule buttons with Bootstrap 5
* backups: Fix issue with verifying SSH hosts with RSA key
* backups: Fix issue with verifying remote server identity
* backups: Format better when showing archive time delete page
* backups: Handle common errors during borg operations
* backups: Parse borg errors from all operations and not just some
* backups: Remove unused import contextlib
* backups: Require POST method for mount/unmount operations
* backups: Use ISO timestamp for auto-naming archives
* backups: tests: functional: Wait for pages to load after click
* container: Add support for VMs using libvirt
* container: Generalize language in output messages for VMs
* container: Minor refactoring to reduce repeated code
* container: Refactor nspawn specific operations into a separate class
* container: Update FSID inside the image file to keep it bootable
* context_processors: Use breadcrumbs to highlight current section
* debian: Remove dependency libjs-jquery
* diagnostics: Use generic handler to handle exceptions in diagnostics
* frontpage: Replace short description with tags
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, German, Tamil, Telugu, Turkish
* Makefile: Add i2p to list of apps to remove
* mediawiki: tests: functional: Use click function from functional library
* menu: Ensure that all menu items have names for use by breadcrumbs
* menu: Implement a helper method to lookup menu items using URL name
* middleware: Handle method not allowed errors and redirect
* middleware: Handle page not found errors specially
* minetest: Provide default gameid argument
* miniflux: tests: functional: Use helper functions from functional library
* networks: Fix error during creation of PPPoE connections
* samba: tests: functional: Wait for page update after enable/disable share
* sharing: tests: functional: Use click function from functional library
* system: Add tags to all remaining apps
* tags: Add button to clear all tags
* tags: Add tags to system apps
* tags: Remove short description from system apps
* tags: Replace short description with tags in app pages
* tests: functional: Add utility to click element wait for page update
* tests: functional: Fix typos in diagnostics checks
* tests: functional: Make first wizard run more robust
* tor, torproxy: Fix daemon services are running after reboot when app is disabled
* torproxy: Don't disable apt-transport-tor in setup
* ui: Don't show breadcrumbs in login and first wizard pages
* ui: Fix regression with margin above app title
* ui: Replace use of jQuery with plain !JavaScript
* users: Restart nslcd service after configuration changes during setup
* users: tests: functional: Use click function from functional library
* views: Implement retrieving breadcrumbs of a page
* views: Show exception details with the utility to show errors
* zoph: Include tags from the manifest
== FreedomBox 24.25 (2024-12-16) == == FreedomBox 24.25 (2024-12-16) ==
=== Highlights === === Highlights ===

View File

@ -8,6 +8,82 @@ 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 24.26.1 (2025-01-05) ==
=== Highlights ===
* email: Fix DKIM signing by setting correct ownership on private keys
* nextcloud: remove experimental warning
=== Other Changes ===
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, French, German, Turkish
* mumble: Add diagnostic for setup config changes
* mumble: Support config file moved into /etc/mumble
* sharing: Drop jQuery code as the library dependency has been removed
* users: Drop jQuery code as the library dependency has been removed
== FreedomBox 24.26 (2024-12-30) ==
=== Highlights ===
* apps: Replace short description with tags in apps list
* ui: Show breadcrumbs on deeper pages
* ui: Handle and show most page load errors as alerts
=== Other Changes ===
* actions: Allow privileged methods to be decorated again
* backups: Fix issue clicking on schedule buttons with Bootstrap 5
* backups: Fix issue with verifying SSH hosts with RSA key
* backups: Fix issue with verifying remote server identity
* backups: Format better when showing archive time delete page
* backups: Handle common errors during borg operations
* backups: Parse borg errors from all operations and not just some
* backups: Remove unused import contextlib
* backups: Require POST method for mount/unmount operations
* backups: Use ISO timestamp for auto-naming archives
* backups: tests: functional: Wait for pages to load after click
* container: Add support for VMs using libvirt
* container: Generalize language in output messages for VMs
* container: Minor refactoring to reduce repeated code
* container: Refactor nspawn specific operations into a separate class
* container: Update FSID inside the image file to keep it bootable
* context_processors: Use breadcrumbs to highlight current section
* debian: Remove dependency libjs-jquery
* diagnostics: Use generic handler to handle exceptions in diagnostics
* frontpage: Replace short description with tags
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, German, Tamil, Telugu, Turkish
* Makefile: Add i2p to list of apps to remove
* mediawiki: tests: functional: Use click function from functional library
* menu: Ensure that all menu items have names for use by breadcrumbs
* menu: Implement a helper method to lookup menu items using URL name
* middleware: Handle method not allowed errors and redirect
* middleware: Handle page not found errors specially
* minetest: Provide default gameid argument
* miniflux: tests: functional: Use helper functions from functional library
* networks: Fix error during creation of PPPoE connections
* samba: tests: functional: Wait for page update after enable/disable share
* sharing: tests: functional: Use click function from functional library
* system: Add tags to all remaining apps
* tags: Add button to clear all tags
* tags: Add tags to system apps
* tags: Remove short description from system apps
* tags: Replace short description with tags in app pages
* tests: functional: Add utility to click element wait for page update
* tests: functional: Fix typos in diagnostics checks
* tests: functional: Make first wizard run more robust
* tor, torproxy: Fix daemon services are running after reboot when app is disabled
* torproxy: Don't disable apt-transport-tor in setup
* ui: Don't show breadcrumbs in login and first wizard pages
* ui: Fix regression with margin above app title
* ui: Replace use of jQuery with plain !JavaScript
* users: Restart nslcd service after configuration changes during setup
* users: tests: functional: Use click function from functional library
* views: Implement retrieving breadcrumbs of a page
* views: Show exception details with the utility to show errors
* zoph: Include tags from the manifest
== FreedomBox 24.25 (2024-12-16) == == FreedomBox 24.25 (2024-12-16) ==
=== Highlights === === Highlights ===

View File

@ -3,4 +3,4 @@
Package init file. Package init file.
""" """
__version__ = '24.25' __version__ = '24.26.1'

View File

@ -383,7 +383,14 @@ def _privileged_call(module_name, action_name, arguments):
if not getattr(action, '_privileged', None): if not getattr(action, '_privileged', None):
raise SyntaxError('Specified action is not privileged action') raise SyntaxError('Specified action is not privileged action')
func = getattr(action, '__wrapped__') # Get the original function that may have been wrapped/decorated multiple
# times
func = action
while True:
try:
func = getattr(func, '__wrapped__')
except AttributeError:
break
_privileged_assert_valid_arguments(func, arguments) _privileged_assert_valid_arguments(func, arguments)

View File

@ -266,3 +266,17 @@ def fixture_host_sudo(host):
"""Pytest fixture to run commands with sudo.""" """Pytest fixture to run commands with sudo."""
with host.sudo(): with host.sudo():
yield host yield host
@pytest.fixture(name='test_menu')
def fixture_test_menu():
"""Initialized menu module."""
from plinth import menu as menu_module
menu_module.Menu._all_menus = set()
menu_module.init()
menu_module.Menu('home-id', name='Home', url_name='index')
menu_module.Menu('apps-id', name='Apps', url_name='apps',
parent_url_name='index')
menu_module.Menu('testapp-id', name='Test App', url_name='testapp:index',
parent_url_name='apps')

View File

@ -3,12 +3,10 @@
Django context processors to provide common data to templates. Django context processors to provide common data to templates.
""" """
import re
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop from django.utils.translation import gettext_noop
from plinth import cfg, web_server from plinth import cfg, views, web_server
from plinth.utils import is_user_admin from plinth.utils import is_user_admin
@ -26,13 +24,15 @@ def common(request):
notifications_context = Notification.get_display_context( notifications_context = Notification.get_display_context(
request, user=request.user) request, user=request.user)
slash_indices = [match.start() for match in re.finditer('/', request.path)] breadcrumbs = views.get_breadcrumbs(request)
active_menu_urls = [ active_section_url = [
request.path[:index + 1] for index in slash_indices[2:] key for key, value in breadcrumbs.items()
] # Ignore the first two slashes '/plinth/apps/' if value.get('is_active_section')
][0]
return { return {
'cfg': cfg, 'cfg': cfg,
'active_menu_urls': active_menu_urls, 'breadcrumbs': breadcrumbs,
'active_section_url': active_section_url,
'box_name': _(cfg.box_name), 'box_name': _(cfg.box_name),
'user_is_admin': is_user_admin(request, True), 'user_is_admin': is_user_admin(request, True),
'user_css': web_server.get_user_css(), 'user_css': web_server.get_user_css(),

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

File diff suppressed because it is too large Load Diff

View File

@ -90,6 +90,18 @@ class Menu(app.FollowerComponent):
return None return None
@staticmethod
def get_with_url_name(url_name: str) -> 'Menu':
"""Return a menu item with given URL name.
Raise LookupError of the request item is not found.
"""
for item in Menu._all_menus:
if item.url_name == url_name:
return item
raise LookupError
main_menu = None main_menu = None
@ -97,10 +109,10 @@ main_menu = None
def init(): def init():
"""Create main menu and other essential menus.""" """Create main menu and other essential menus."""
global main_menu global main_menu
main_menu = Menu('menu-index', url_name='index') main_menu = Menu('menu-index', name=_('Home'), url_name='index')
Menu('menu-apps', icon='fa-download', url_name='apps', Menu('menu-apps', name=_('Apps'), icon='fa-download', url_name='apps',
parent_url_name='index') parent_url_name='index')
Menu('menu-system', icon='fa-cog', url_name='system', Menu('menu-system', name=_('System'), icon='fa-cog', url_name='system',
parent_url_name='index') parent_url_name='index')
Menu('menu-system-visibility', name=_('Visibility'), icon='fa-cog', Menu('menu-system-visibility', name=_('Visibility'), icon='fa-cog',

View File

@ -11,7 +11,8 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.utils import OperationalError from django.db.utils import OperationalError
from django.shortcuts import render from django.http import Http404, HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.template.response import SimpleTemplateResponse from django.template.response import SimpleTemplateResponse
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -126,6 +127,8 @@ class CommonErrorMiddleware(MiddlewareMixin):
@staticmethod @staticmethod
def process_exception(request, exception): def process_exception(request, exception):
"""Show a custom error page when OperationalError is raised.""" """Show a custom error page when OperationalError is raised."""
logger.exception('Error processing page. %s %s, exception: %s',
request.method, request.path, exception)
if isinstance(exception, OperationalError): if isinstance(exception, OperationalError):
message = _( message = _(
'System is possibly under heavy load. Please retry later.') 'System is possibly under heavy load. Please retry later.')
@ -133,4 +136,55 @@ class CommonErrorMiddleware(MiddlewareMixin):
context={'message': message}, context={'message': message},
status=503) status=503)
if isinstance(exception, Exception):
match = request.resolver_match
if not match.app_name and match.url_name == 'index':
# Don't try to handle errors on the home page as it will lead
# to infinite redirects.
return None return None
if isinstance(exception, Http404):
message = _('Page not found: {url}').format(url=request.path)
exception = None # Don't show exception details
elif request.method == 'POST':
message = _('Error running operation.')
else:
message = _('Error loading page.')
if exception:
views.messages_error(request, message, exception)
else:
messages.error(request, message)
redirect_url = CommonErrorMiddleware._get_redirect_url_on_error(
request)
return redirect(redirect_url)
return None
@staticmethod
def process_response(request, response):
"""Handle 405 method not allowed errors.
These errors may happen when we redirect to a page that does not allow
GET.
"""
if isinstance(response, HttpResponseNotAllowed):
redirect_url = CommonErrorMiddleware._get_redirect_url_on_error(
request)
return redirect(redirect_url)
return response
@staticmethod
def _get_redirect_url_on_error(request):
"""Return the URL to redirect to after an error."""
if request.method != 'GET':
return request.path
# If the original request was a GET, trying to redirect to same URL
# with same request method might result in an recursive loop. Instead
# redirect to a parent URL.
breadcrumbs = views.get_breadcrumbs(request)
parent_index = 1 if len(breadcrumbs) > 1 else 0
return list(breadcrumbs.keys())[parent_index]

View File

@ -46,7 +46,8 @@ class AvahiApp(app_module.App):
is_essential=True, depends=['names'], is_essential=True, depends=['names'],
name=_('Service Discovery'), icon='fa-compass', name=_('Service Discovery'), icon='fa-compass',
description=_description, description=_description,
manual_page='ServiceDiscovery') manual_page='ServiceDiscovery',
tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-avahi', info.name, None, info.icon, menu_item = menu.Menu('menu-avahi', info.name, None, info.icon,

View File

@ -3,8 +3,12 @@
Application manifest for avahi. Application manifest for avahi.
""" """
from django.utils.translation import gettext_lazy as _
# Services that intend to make themselves discoverable will drop files into # Services that intend to make themselves discoverable will drop files into
# /etc/avahi/services. Currently, we don't intend to make that customizable. # /etc/avahi/services. Currently, we don't intend to make that customizable.
# There is no necessity for backup and restore. This manifest will ensure that # There is no necessity for backup and restore. This manifest will ensure that
# avahi enable/disable setting is preserved. # avahi enable/disable setting is preserved.
backup: dict = {} backup: dict = {}
tags = [_('Auto-discovery'), _('Local'), _('mDNS')]

View File

@ -6,8 +6,8 @@ import logging
import os import os
import pathlib import pathlib
import re import re
import subprocess
import paramiko
from django.utils.text import get_valid_filename 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
@ -16,7 +16,7 @@ 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, privileged from . import api, manifest, privileged
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,7 +43,8 @@ class BackupsApp(app_module.App):
app_id=self.app_id, version=self._version, is_essential=True, app_id=self.app_id, version=self._version, is_essential=True,
depends=['storage'], name=_('Backups'), icon='fa-files-o', depends=['storage'], name=_('Backups'), icon='fa-files-o',
description=_description, manual_page='Backups', description=_description, manual_page='Backups',
donation_url='https://www.borgbackup.org/support/fund.html') donation_url='https://www.borgbackup.org/support/fund.html',
tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-backups', info.name, None, info.icon, menu_item = menu.Menu('menu-backups', info.name, None, info.icon,
@ -51,7 +52,8 @@ class BackupsApp(app_module.App):
order=20) order=20)
self.add(menu_item) self.add(menu_item)
packages = Packages('packages-backups', ['borgbackup', 'sshfs']) packages = Packages('packages-backups',
['borgbackup', 'sshfs', 'sshpass'])
self.add(packages) self.add(packages)
@staticmethod @staticmethod
@ -143,9 +145,13 @@ def is_ssh_hostkey_verified(hostname):
if not known_hosts_path.exists(): if not known_hosts_path.exists():
return False return False
known_hosts = paramiko.hostkeys.HostKeys(str(known_hosts_path)) try:
host_keys = known_hosts.lookup(hostname) subprocess.run(
return host_keys is not None ['ssh-keygen', '-F', hostname, '-f',
str(known_hosts_path)], check=True)
return True
except subprocess.CalledProcessError:
return False
def split_path(path): def split_path(path):

View File

@ -21,3 +21,15 @@ class BorgRepositoryExists(BorgError):
class BorgUnencryptedRepository(BorgError): class BorgUnencryptedRepository(BorgError):
"""Attempt to provide password on an unencrypted repository.""" """Attempt to provide password on an unencrypted repository."""
class BorgArchiveExists(BorgError):
"""A archive with the given name already exists in the repository."""
class BorgArchiveDoesNotExist(BorgError):
"""Specified archive does not exist in the repository."""
class BorgBusy(BorgError):
"""Borg could not acquire lock being busy with another operation."""

View File

@ -292,7 +292,8 @@ class VerifySshHostkeyForm(forms.Form):
keyscan = subprocess.run(['ssh-keyscan', hostname], keyscan = subprocess.run(['ssh-keyscan', hostname],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=False) stderr=subprocess.PIPE, check=False)
keys = keyscan.stdout.decode().splitlines() key_lines = keyscan.stdout.decode().splitlines()
keys = [line for line in key_lines if not line.startswith('#')]
error_message = keyscan.stderr.decode() if keyscan.returncode else None error_message = keyscan.stderr.decode() if keyscan.returncode else None
# Generate user-friendly fingerprints of public keys # Generate user-friendly fingerprints of public keys
keygen = subprocess.run(['ssh-keygen', '-l', '-f', '-'], keygen = subprocess.run(['ssh-keygen', '-l', '-f', '-'],

View File

@ -3,7 +3,20 @@
Application manifest for backups. Application manifest for backups.
""" """
from django.utils.translation import gettext_lazy as _
# Currently, backup application does not have any settings. However, settings # Currently, backup application does not have any settings. However, settings
# such as scheduler settings, backup location, secrets to connect to remove # such as scheduler settings, backup location, secrets to connect to remove
# servers need to be backed up. # servers need to be backed up.
backup: dict = {} backup: dict = {}
tags = [
_('Restore'),
_('Encrypted'),
_('Schedules'),
_('Local'),
_('Remote'),
_('App data'),
_('Configuration'),
_('Borg')
]

View File

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure backups (with borg) and sshfs.""" """Configure backups (with borg) and sshfs."""
import functools
import json import json
import os import os
import pathlib import pathlib
@ -8,20 +9,119 @@ import re
import subprocess import subprocess
import tarfile import tarfile
from django.utils.translation import gettext_lazy as _
from plinth import action_utils from plinth import action_utils
from plinth.actions import privileged, secret_str from plinth.actions import privileged, secret_str
from plinth.utils import Version from plinth.utils import Version
from . import errors
TIMEOUT = 30 TIMEOUT = 30
BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/') BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/')
BACKUPS_UPLOAD_PATH = pathlib.Path('/var/lib/freedombox/backups-upload/') BACKUPS_UPLOAD_PATH = pathlib.Path('/var/lib/freedombox/backups-upload/')
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/' MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
# known errors that come up when remotely accessing a borg repository
# 'errors' are error strings to look for in the stacktrace.
KNOWN_ERRORS = [
{
'errors': ['subprocess.TimeoutExpired'],
'message':
_('Connection refused - make sure you provided correct '
'credentials and the server is running.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['Connection refused'],
'message': _('Connection refused'),
'raise_as': errors.BorgError,
},
{
'errors': [
'not a valid repository', 'does not exist', 'FileNotFoundError'
],
'message': _('Repository not found'),
'raise_as': errors.BorgRepositoryDoesNotExistError,
},
{
'errors': ['passphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'),
'raise_as': errors.BorgError,
},
{
'errors': ['Connection reset by peer'],
'message': _('SSH access denied'),
'raise_as': errors.SshfsError,
},
{
'errors': ['There is already something at'],
'message':
_('Repository path is neither empty nor '
'is an existing backups repository.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['A repository already exists at'],
'message': None,
'raise_as': errors.BorgRepositoryExists,
},
{
'errors': ['Archive .* already exists'],
'message':
_('An archive with given name already exists in the repository.'),
'raise_as':
errors.BorgArchiveExists,
},
{
'errors': ['Archive .* not found'],
'message':
_('Archive with given name was not found in the repository.'),
'raise_as':
errors.BorgArchiveDoesNotExist,
},
{
'errors': ['Failed to create/acquire the lock'],
'message': _('Backup system is busy with another operation.'),
'raise_as': errors.BorgBusy,
},
]
class AlreadyMountedError(Exception): class AlreadyMountedError(Exception):
"""Exception raised when mount point is already mounted.""" """Exception raised when mount point is already mounted."""
def reraise_known_errors(privileged_func):
"""Decorator to convert borg raised exceptions to specialized ones."""
@functools.wraps(privileged_func)
def wrapper(*args, **kwargs):
"""Run privileged method, catch exceptions and throw new ones."""
try:
return privileged_func(*args, **kwargs)
except Exception as exception:
_reraise_known_errors(exception)
return wrapper
def _reraise_known_errors(err):
"""Look whether the caught error is known and reraise it accordingly"""
stdout = getattr(err, 'stdout', b'').decode()
stderr = getattr(err, 'stderr', b'').decode()
caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS:
for error in known_error['errors']:
if re.search(error, caught_error):
raise known_error['raise_as'](known_error['message'])
raise err
@reraise_known_errors
@privileged @privileged
def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None, def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
password: secret_str | None = None, password: secret_str | None = None,
@ -61,6 +161,7 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
subprocess.run(cmd, check=True, timeout=TIMEOUT, input=input_) subprocess.run(cmd, check=True, timeout=TIMEOUT, input=input_)
@reraise_known_errors
@privileged @privileged
def umount(mountpoint: str): def umount(mountpoint: str):
"""Unmount a mountpoint.""" """Unmount a mountpoint."""
@ -91,12 +192,14 @@ def _is_mounted(mountpoint):
return False return False
@reraise_known_errors
@privileged @privileged
def is_mounted(mount_point: str) -> bool: def is_mounted(mount_point: str) -> bool:
"""Return whether a path is already mounted.""" """Return whether a path is already mounted."""
return _is_mounted(mount_point) return _is_mounted(mount_point)
@reraise_known_errors
@privileged @privileged
def setup(path: str): def setup(path: str):
"""Create repository if it does not already exist.""" """Create repository if it does not already exist."""
@ -121,6 +224,7 @@ def _init_repository(path: str, encryption: str,
_run(cmd, encryption_passphrase) _run(cmd, encryption_passphrase)
@reraise_known_errors
@privileged @privileged
def init(path: str, encryption: str, def init(path: str, encryption: str,
encryption_passphrase: secret_str | None = None): encryption_passphrase: secret_str | None = None):
@ -128,6 +232,7 @@ def init(path: str, encryption: str,
_init_repository(path, encryption, encryption_passphrase) _init_repository(path, encryption, encryption_passphrase)
@reraise_known_errors
@privileged @privileged
def info(path: str, encryption_passphrase: secret_str | None = None) -> dict: def info(path: str, encryption_passphrase: secret_str | None = None) -> dict:
"""Show repository information.""" """Show repository information."""
@ -136,6 +241,7 @@ def info(path: str, encryption_passphrase: secret_str | None = None) -> dict:
return json.loads(process.stdout.decode()) return json.loads(process.stdout.decode())
@reraise_known_errors
@privileged @privileged
def list_repo(path: str, def list_repo(path: str,
encryption_passphrase: secret_str | None = None) -> dict: encryption_passphrase: secret_str | None = None) -> dict:
@ -145,6 +251,7 @@ def list_repo(path: str,
return json.loads(process.stdout.decode()) return json.loads(process.stdout.decode())
@reraise_known_errors
@privileged @privileged
def add_uploaded_archive(file_name: str, temporary_file_path: str): def add_uploaded_archive(file_name: str, temporary_file_path: str):
"""Store an archive uploaded by the user.""" """Store an archive uploaded by the user."""
@ -154,6 +261,7 @@ def add_uploaded_archive(file_name: str, temporary_file_path: str):
permissions=0o600) permissions=0o600)
@reraise_known_errors
@privileged @privileged
def remove_uploaded_archive(file_path: str): def remove_uploaded_archive(file_path: str):
"""Delete the archive uploaded by the user.""" """Delete the archive uploaded by the user."""
@ -169,6 +277,7 @@ def _get_borg_version():
return process.stdout.decode().split()[1] # Example: "borg 1.1.9" return process.stdout.decode().split()[1] # Example: "borg 1.1.9"
@reraise_known_errors
@privileged @privileged
def create_archive(path: str, paths: list[str], comment: str | None = None, def create_archive(path: str, paths: list[str], comment: str | None = None,
encryption_passphrase: secret_str | None = None): encryption_passphrase: secret_str | None = None):
@ -188,6 +297,7 @@ def create_archive(path: str, paths: list[str], comment: str | None = None,
_run(command, encryption_passphrase) _run(command, encryption_passphrase)
@reraise_known_errors
@privileged @privileged
def delete_archive(path: str, encryption_passphrase: secret_str | None = None): def delete_archive(path: str, encryption_passphrase: secret_str | None = None):
"""Delete archive.""" """Delete archive."""
@ -218,6 +328,7 @@ def _extract(archive_path, destination, encryption_passphrase, locations=None):
os.chdir(prev_dir) os.chdir(prev_dir)
@reraise_known_errors
@privileged @privileged
def export_tar(path: str, encryption_passphrase: secret_str | None = None): def export_tar(path: str, encryption_passphrase: secret_str | None = None):
"""Export archive contents as tar stream on stdout.""" """Export archive contents as tar stream on stdout."""
@ -232,6 +343,7 @@ def _read_archive_file(archive, filepath, encryption_passphrase):
stdout=subprocess.PIPE).stdout.decode() stdout=subprocess.PIPE).stdout.decode()
@reraise_known_errors
@privileged @privileged
def get_archive_apps( def get_archive_apps(
path: str, path: str,
@ -278,6 +390,7 @@ def _get_apps_of_manifest(manifest):
return apps return apps
@reraise_known_errors
@privileged @privileged
def get_exported_archive_apps(path: str) -> list[str]: def get_exported_archive_apps(path: str) -> list[str]:
"""Get list of apps included in an exported archive file.""" """Get list of apps included in an exported archive file."""
@ -304,6 +417,7 @@ def get_exported_archive_apps(path: str) -> list[str]:
return app_names return app_names
@reraise_known_errors
@privileged @privileged
def restore_archive(archive_path: str, destination: str, def restore_archive(archive_path: str, destination: str,
directories: list[str], files: list[str], directories: list[str], files: list[str],
@ -317,6 +431,7 @@ def restore_archive(archive_path: str, destination: str,
locations=locations_all) locations=locations_all)
@reraise_known_errors
@privileged @privileged
def restore_exported_archive(path: str, directories: list[str], def restore_exported_archive(path: str, directories: list[str],
files: list[str]): files: list[str]):
@ -339,6 +454,7 @@ def _assert_app_id(app_id):
raise Exception('Invalid App ID') raise Exception('Invalid App ID')
@reraise_known_errors
@privileged @privileged
def dump_settings(app_id: str, settings: dict[str, int | float | bool | str]): def dump_settings(app_id: str, settings: dict[str, int | float | bool | str]):
"""Dump an app's settings to a JSON file.""" """Dump an app's settings to a JSON file."""
@ -348,6 +464,7 @@ def dump_settings(app_id: str, settings: dict[str, int | float | bool | str]):
settings_path.write_text(json.dumps(settings)) settings_path.write_text(json.dumps(settings))
@reraise_known_errors
@privileged @privileged
def load_settings(app_id: str) -> dict[str, int | float | bool | str]: def load_settings(app_id: str) -> dict[str, int | float | bool | str]:
"""Load an app's settings from a JSON file.""" """Load an app's settings from a JSON file."""

View File

@ -2,14 +2,13 @@
"""Remote and local Borg backup repositories.""" """Remote and local Borg backup repositories."""
import abc import abc
import contextlib import datetime
import io import io
import logging import logging
import os import os
import re import subprocess
from uuid import uuid1 from uuid import uuid1
import paramiko
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import cfg from plinth import cfg
@ -21,54 +20,6 @@ from .schedule import Schedule
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# known errors that come up when remotely accessing a borg repository
# 'errors' are error strings to look for in the stacktrace.
KNOWN_ERRORS = [
{
'errors': ['subprocess.TimeoutExpired'],
'message':
_('Connection refused - make sure you provided correct '
'credentials and the server is running.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['Connection refused'],
'message': _('Connection refused'),
'raise_as': errors.BorgError,
},
{
'errors': [
'not a valid repository', 'does not exist', 'FileNotFoundError'
],
'message': _('Repository not found'),
'raise_as': errors.BorgRepositoryDoesNotExistError,
},
{
'errors': ['passphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'),
'raise_as': errors.BorgError,
},
{
'errors': ['Connection reset by peer'],
'message': _('SSH access denied'),
'raise_as': errors.SshfsError,
},
{
'errors': ['There is already something at'],
'message':
_('Repository path is neither empty nor '
'is an existing backups repository.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['A repository already exists at'],
'message': None,
'raise_as': errors.BorgRepositoryExists,
},
]
class BaseBorgRepository(abc.ABC): class BaseBorgRepository(abc.ABC):
"""Base class for all kinds of Borg repositories.""" """Base class for all kinds of Borg repositories."""
@ -135,10 +86,8 @@ class BaseBorgRepository(abc.ABC):
def get_info(self): def get_info(self):
"""Return Borg information about a repository.""" """Return Borg information about a repository."""
with self._handle_errors():
output = privileged.info(self.borg_path, output = privileged.info(self.borg_path,
self._get_encryption_passpharse()) 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(
@ -170,9 +119,14 @@ 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."""
with self._handle_errors():
archives = privileged.list_repo( archives = privileged.list_repo(
self.borg_path, self._get_encryption_passpharse())['archives'] self.borg_path, self._get_encryption_passpharse())['archives']
for archive in archives:
archive['time'] = datetime.datetime.strptime(
archive['time'], '%Y-%m-%dT%H:%M:%S.%f')
archive['start'] = datetime.datetime.strptime(
archive['start'], '%Y-%m-%dT%H:%M:%S.%f')
return sorted(archives, key=lambda archive: archive['start'], return sorted(archives, key=lambda archive: archive['start'],
reverse=True) reverse=True)
@ -187,7 +141,6 @@ 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)
with self._handle_errors():
privileged.delete_archive(archive_path, privileged.delete_archive(archive_path,
self._get_encryption_passpharse()) self._get_encryption_passpharse())
@ -199,7 +152,6 @@ class BaseBorgRepository(abc.ABC):
encryption = 'repokey' encryption = 'repokey'
try: try:
with self._handle_errors():
privileged.init(self.borg_path, encryption, privileged.init(self.borg_path, encryption,
self._get_encryption_passpharse()) self._get_encryption_passpharse())
except errors.BorgRepositoryExists: except errors.BorgRepositoryExists:
@ -215,14 +167,6 @@ class BaseBorgRepository(abc.ABC):
return {} return {}
@contextlib.contextmanager
def _handle_errors(self):
"""Parse exceptions into more specific ones."""
try:
yield
except Exception as exception:
self.reraise_known_error(exception)
def _get_encryption_passpharse(self): def _get_encryption_passpharse(self):
"""Return encryption passphrase or raise an exception.""" """Return encryption passphrase or raise an exception."""
for key in self.credentials.keys(): for key in self.credentials.keys():
@ -256,7 +200,6 @@ class BaseBorgRepository(abc.ABC):
return chunk return chunk
with self._handle_errors():
proc, read_fd, input_ = privileged.export_tar( proc, read_fd, input_ = privileged.export_tar(
self._get_archive_path(archive_name), self._get_archive_path(archive_name),
self._get_encryption_passpharse(), _raw_output=True) self._get_encryption_passpharse(), _raw_output=True)
@ -272,19 +215,6 @@ class BaseBorgRepository(abc.ABC):
"""Return full borg path for an archive.""" """Return full borg path for an archive."""
return '::'.join([self.borg_path, archive_name]) return '::'.join([self.borg_path, archive_name])
@staticmethod
def reraise_known_error(err):
"""Look whether the caught error is known and reraise it accordingly"""
stdout = getattr(err, 'stdout', b'').decode()
stderr = getattr(err, 'stderr', b'').decode()
caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS:
for error in known_error['errors']:
if re.search(error, caught_error):
raise known_error['raise_as'](known_error['message'])
raise err
def get_archive(self, name): def get_archive(self, name):
"""Return a specific archive from this repository with given name.""" """Return a specific archive from this repository with given name."""
for archive in self.list_archives(): for archive in self.list_archives():
@ -296,9 +226,8 @@ 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)
with self._handle_errors(): return privileged.get_archive_apps(archive_path,
return privileged.get_archive_apps( self._get_encryption_passpharse())
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."""
@ -424,7 +353,6 @@ 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."""
with self._handle_errors():
return privileged.is_mounted(self._mountpoint) return privileged.is_mounted(self._mountpoint)
def initialize(self): def initialize(self):
@ -448,7 +376,6 @@ class SshBorgRepository(BaseBorgRepository):
'ssh_keyfile']: 'ssh_keyfile']:
kwargs['ssh_keyfile'] = self.credentials['ssh_keyfile'] kwargs['ssh_keyfile'] = self.credentials['ssh_keyfile']
with self._handle_errors():
privileged.mount(self._mountpoint, self._path, **kwargs) privileged.mount(self._mountpoint, self._path, **kwargs)
def umount(self): def umount(self):
@ -456,7 +383,6 @@ class SshBorgRepository(BaseBorgRepository):
if not self.is_mounted: if not self.is_mounted:
return return
with self._handle_errors():
privileged.umount(self._mountpoint) privileged.umount(self._mountpoint)
def _umount_ignore_errors(self): def _umount_ignore_errors(self):
@ -493,28 +419,13 @@ class SshBorgRepository(BaseBorgRepository):
password = self.credentials['ssh_password'] password = self.credentials['ssh_password']
# Ensure remote directory exists, check contents # Ensure remote directory exists, check contents
# TODO Test with IPv6 connection env = {'SSHPASS': password}
with _ssh_connection(hostname, username, password) as ssh_client: known_hosts_path = str(get_known_hosts_path())
with ssh_client.open_sftp() as sftp_client: subprocess.run([
try: 'sshpass', '-e', 'ssh', '-o',
sftp_client.listdir(dir_path) f'UserKnownHostsFile={known_hosts_path}', f'{username}@{hostname}',
except FileNotFoundError: 'mkdir', '-p', dir_path
logger.info('Directory %s does not exist, creating.', ], check=True, env=env)
dir_path)
sftp_client.mkdir(dir_path)
@contextlib.contextmanager
def _ssh_connection(hostname, username, password):
"""Context manager to create and close an SSH connection."""
ssh_client = paramiko.SSHClient()
ssh_client.load_host_keys(str(get_known_hosts_path()))
try:
ssh_client.connect(hostname, username=username, password=password)
yield ssh_client
finally:
ssh_client.close()
def get_repositories(): def get_repositories():

View File

@ -242,14 +242,11 @@ class Schedule:
archive['comment'] = comment archive['comment'] = comment
start_time = datetime.strptime(archive['start'], if archive['start'] > now:
'%Y-%m-%dT%H:%M:%S.%f')
if start_time > now:
# This backup was taken when clock was set in future. Ignore it # This backup was taken when clock was set in future. Ignore it
# to ensure backups continue to be taken. # to ensure backups continue to be taken.
continue continue
archive['start'] = start_time
scheduled_archives.append(archive) scheduled_archives.append(archive)
return scheduled_archives return scheduled_archives

View File

@ -23,3 +23,7 @@
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
.archive-operations {
width: 12.5rem;
}

View File

@ -6,9 +6,12 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table" id="archives-list"> <table class="table" id="archives-list">
<thead class="collapsible-button" data-bs-toggle="collapse" data-bs-target="#{{ uuid }}"> <thead>
<tr> <tr>
<th colspan="2"> <th colspan="2">
<div class="d-sm-flex flex-sm-row">
<div class="flex-sm-grow-1 lh-lg collapsible-button"
data-bs-toggle="collapse" data-bs-target="#{{ uuid }}">
<span class="fa fa-chevron-right fa-fw" aria-hidden="true"></span> <span class="fa fa-chevron-right fa-fw" aria-hidden="true"></span>
{% if repository.error %} {% if repository.error %}
<span class="fa fa-exclamation-triangle mount-error" <span class="fa fa-exclamation-triangle mount-error"
@ -21,8 +24,9 @@
{% endif %} {% endif %}
{{ repository.name }} {{ repository.name }}
</div>
<span class="pull-right"> <div class="text-end">
<a class="repository-schedule btn btn-sm btn-primary" <a class="repository-schedule btn btn-sm btn-primary"
href="{% url 'backups:schedule' uuid %}"> href="{% url 'backups:schedule' uuid %}">
<span class="fa fa-clock-o" aria-hidden="true"></span> <span class="fa fa-clock-o" aria-hidden="true"></span>
@ -66,7 +70,8 @@
</a> </a>
{% endif %} {% endif %}
</span> </div>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>

View File

@ -106,8 +106,10 @@ def _backup_schedule_disable(session_browser):
def _backup_schedule_get(browser): def _backup_schedule_get(browser):
"""Return the current schedule set for the root repository.""" """Return the current schedule set for the root repository."""
functional.nav_to_module(browser, 'backups') functional.nav_to_module(browser, 'backups')
with functional.wait_for_page_update(browser):
browser.links.find_by_href( browser.links.find_by_href(
'/plinth/sys/backups/root/schedule/').first.click() '/plinth/sys/backups/root/schedule/').first.click()
without_apps = [] without_apps = []
elements = browser.find_by_name('backups_schedule-selected_apps') elements = browser.find_by_name('backups_schedule-selected_apps')
for element in elements: for element in elements:
@ -136,8 +138,10 @@ def _backup_schedule_set(browser, enable, daily, weekly, monthly, run_at,
without_app): without_app):
"""Set the schedule for root repository.""" """Set the schedule for root repository."""
functional.nav_to_module(browser, 'backups') functional.nav_to_module(browser, 'backups')
with functional.wait_for_page_update(browser):
browser.links.find_by_href( browser.links.find_by_href(
'/plinth/sys/backups/root/schedule/').first.click() '/plinth/sys/backups/root/schedule/').first.click()
if enable: if enable:
browser.find_by_name('backups_schedule-enabled').check() browser.find_by_name('backups_schedule-enabled').check()
else: else:
@ -182,7 +186,9 @@ def _open_main_page(browser):
def _upload_and_restore(browser, app_name, downloaded_file_path): def _upload_and_restore(browser, app_name, downloaded_file_path):
functional.nav_to_module(browser, 'backups') functional.nav_to_module(browser, 'backups')
with functional.wait_for_page_update(browser):
browser.links.find_by_href('/plinth/sys/backups/upload/').first.click() browser.links.find_by_href('/plinth/sys/backups/upload/').first.click()
fileinput = browser.find_by_id('id_backups-file') fileinput = browser.find_by_id('id_backups-file')
fileinput.fill(downloaded_file_path) fileinput.fill(downloaded_file_path)
# submit upload form # submit upload form

View File

@ -84,16 +84,12 @@ def _get_archives_from_test_data(data):
if isinstance(archive_time, str): if isinstance(archive_time, str):
archive_time = datetime.strptime(archive_time, archive_time = datetime.strptime(archive_time,
'%Y-%m-%d %H:%M:%S+0000') '%Y-%m-%d %H:%M:%S+0000')
comment = json.dumps({'type': 'scheduled', 'periods': item['periods']})
archive = { archive = {
'comment': 'comment': comment,
json.dumps({ 'start': archive_time,
'type': 'scheduled', 'name': f'archive-{index}'
'periods': item['periods']
}),
'start':
archive_time.strftime('%Y-%m-%dT%H:%M:%S.%f'),
'name':
f'archive-{index}'
} }
archives.append(archive) archives.append(archive)

View File

@ -3,27 +3,29 @@
Views for the backups app. Views for the backups app.
""" """
import contextlib
import logging import logging
import os import os
import subprocess
from datetime import datetime from datetime import datetime
from urllib.parse import unquote from urllib.parse import unquote
import paramiko
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404, StreamingHttpResponse from django.http import Http404, HttpRequest, StreamingHttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
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 django.views.decorators.http import require_POST
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
from plinth.errors import PlinthError from plinth.errors import PlinthError
from plinth.modules import backups, storage from plinth.modules import backups, storage
from plinth.views import AppView from plinth.views import AppView
from . import (SESSION_PATH_VARIABLE, api, forms, get_known_hosts_path, from . import (SESSION_PATH_VARIABLE, api, errors, forms, get_known_hosts_path,
is_ssh_hostkey_verified, privileged) is_ssh_hostkey_verified, privileged)
from .decorators import delete_tmp_backup_file from .decorators import delete_tmp_backup_file
from .repository import (BorgRepository, SshBorgRepository, get_instance, from .repository import (BorgRepository, SshBorgRepository, get_instance,
@ -32,6 +34,15 @@ from .repository import (BorgRepository, SshBorgRepository, get_instance,
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@contextlib.contextmanager
def handle_common_errors(request: HttpRequest):
"""If any known Borg exceptions occur, show proper error messages."""
try:
yield
except errors.BorgError as exception:
messages.error(request, exception.args[0])
@method_decorator(delete_tmp_backup_file, name='dispatch') @method_decorator(delete_tmp_backup_file, name='dispatch')
class BackupsView(AppView): class BackupsView(AppView):
"""View to show list of archives.""" """View to show list of archives."""
@ -100,14 +111,13 @@ class ScheduleView(SuccessMessageMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class CreateArchiveView(SuccessMessageMixin, FormView): class CreateArchiveView(FormView):
"""View to create a new archive.""" """View to create a new archive."""
form_class = forms.CreateArchiveForm form_class = forms.CreateArchiveForm
prefix = 'backups' prefix = 'backups'
template_name = 'form.html' template_name = 'form.html'
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:index')
success_message = gettext_lazy('Archive created.')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Return additional context for rendering the template.""" """Return additional context for rendering the template."""
@ -129,14 +139,20 @@ class CreateArchiveView(SuccessMessageMixin, FormView):
if repository.flags.get('mountable'): if repository.flags.get('mountable'):
repository.mount() repository.mount()
name = form.cleaned_data['name'] or datetime.now().strftime( name = form.cleaned_data['name']
'%Y-%m-%d:%H:%M') if not name:
name = datetime.now().astimezone().replace(
microsecond=0).isoformat()
selected_apps = form.cleaned_data['selected_apps'] selected_apps = form.cleaned_data['selected_apps']
with handle_common_errors(self.request):
repository.create_archive(name, selected_apps) repository.create_archive(name, selected_apps)
messages.success(self.request, _('Archive created.'))
return super().form_valid(form) return super().form_valid(form)
class DeleteArchiveView(SuccessMessageMixin, TemplateView): class DeleteArchiveView(TemplateView):
"""View to delete an archive.""" """View to delete an archive."""
template_name = 'backups_delete.html' template_name = 'backups_delete.html'
@ -154,12 +170,14 @@ class DeleteArchiveView(SuccessMessageMixin, TemplateView):
def post(self, request, uuid, name): def post(self, request, uuid, name):
"""Delete the archive.""" """Delete the archive."""
repository = get_instance(uuid) repository = get_instance(uuid)
with handle_common_errors(self.request):
repository.delete_archive(name) repository.delete_archive(name)
messages.success(request, _('Archive deleted.')) messages.success(request, _('Archive deleted.'))
return redirect('backups:index') return redirect('backups:index')
class UploadArchiveView(SuccessMessageMixin, FormView): class UploadArchiveView(FormView):
form_class = forms.UploadForm form_class = forms.UploadForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_upload.html' template_name = 'backups_upload.html'
@ -192,20 +210,22 @@ class UploadArchiveView(SuccessMessageMixin, FormView):
"""Store uploaded file.""" """Store uploaded file."""
uploaded_file = self.request.FILES['backups-file'] uploaded_file = self.request.FILES['backups-file']
# Hold on to Django's uploaded file. It will be used by other views. # Hold on to Django's uploaded file. It will be used by other views.
privileged.add_uploaded_archive(uploaded_file.name, with handle_common_errors(self.request):
uploaded_file.temporary_file_path()) privileged.add_uploaded_archive(
uploaded_file.name, uploaded_file.temporary_file_path())
self.request.session[SESSION_PATH_VARIABLE] = str( self.request.session[SESSION_PATH_VARIABLE] = str(
privileged.BACKUPS_UPLOAD_PATH / uploaded_file.name) privileged.BACKUPS_UPLOAD_PATH / uploaded_file.name)
messages.success(self.request, _('Upload successful.'))
return super().form_valid(form) return super().form_valid(form)
class BaseRestoreView(SuccessMessageMixin, FormView): class BaseRestoreView(FormView):
"""View to restore files from an archive.""" """View to restore files from an archive."""
form_class = forms.RestoreForm form_class = forms.RestoreForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_restore.html' template_name = 'backups_restore.html'
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:index')
success_message = gettext_lazy('Restored files from backup.')
def get_form_kwargs(self): def get_form_kwargs(self):
"""Pass additional keyword args for instantiating the form.""" """Pass additional keyword args for instantiating the form."""
@ -253,7 +273,10 @@ class RestoreFromUploadView(BaseRestoreView):
"""Restore files from the archive on valid form submission.""" """Restore files from the archive on valid form submission."""
path = self.request.session.get(SESSION_PATH_VARIABLE) path = self.request.session.get(SESSION_PATH_VARIABLE)
selected_apps = form.cleaned_data['selected_apps'] selected_apps = form.cleaned_data['selected_apps']
with handle_common_errors(self.request):
backups.restore_from_upload(path, selected_apps) backups.restore_from_upload(path, selected_apps)
messages.success(self.request, _('Restored files from backup.'))
return super().form_valid(form) return super().form_valid(form)
@ -271,7 +294,10 @@ class RestoreArchiveView(BaseRestoreView):
"""Restore files from the archive on valid form submission.""" """Restore files from the archive on valid form submission."""
repository = get_instance(self.kwargs['uuid']) repository = get_instance(self.kwargs['uuid'])
selected_apps = form.cleaned_data['selected_apps'] selected_apps = form.cleaned_data['selected_apps']
with handle_common_errors(self.request):
repository.restore_archive(self.kwargs['name'], selected_apps) repository.restore_archive(self.kwargs['name'], selected_apps)
messages.success(self.request, _('Restored files from backup.'))
return super().form_valid(form) return super().form_valid(form)
@ -289,7 +315,7 @@ class DownloadArchiveView(View):
return response return response
class AddRepositoryView(SuccessMessageMixin, FormView): class AddRepositoryView(FormView):
"""View to create a new backup repository.""" """View to create a new backup repository."""
form_class = forms.AddRepositoryForm form_class = forms.AddRepositoryForm
template_name = 'backups_add_repository.html' template_name = 'backups_add_repository.html'
@ -320,14 +346,16 @@ class AddRepositoryView(SuccessMessageMixin, FormView):
encryption_passphrase = None encryption_passphrase = None
credentials = {'encryption_passphrase': encryption_passphrase} credentials = {'encryption_passphrase': encryption_passphrase}
with handle_common_errors(self.request):
repository = BorgRepository(path, credentials) repository = BorgRepository(path, credentials)
if _save_repository(self.request, repository): if _save_repository(self.request, repository):
messages.success(self.request, _('Added new repository.'))
return super().form_valid(form) return super().form_valid(form)
return redirect(reverse_lazy('backups:add-repository')) return redirect(reverse_lazy('backups:add-repository'))
class AddRemoteRepositoryView(SuccessMessageMixin, FormView): class AddRemoteRepositoryView(FormView):
"""View to create a new remote backup repository.""" """View to create a new remote backup repository."""
form_class = forms.AddRemoteRepositoryForm form_class = forms.AddRemoteRepositoryForm
template_name = 'backups_add_remote_repository.html' template_name = 'backups_add_remote_repository.html'
@ -352,16 +380,18 @@ class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
'ssh_password': form.cleaned_data.get('ssh_password'), 'ssh_password': form.cleaned_data.get('ssh_password'),
'encryption_passphrase': encryption_passphrase 'encryption_passphrase': encryption_passphrase
} }
with handle_common_errors(self.request):
repository = SshBorgRepository(path, credentials) repository = SshBorgRepository(path, credentials)
repository.verfied = False repository.verfied = False
repository.save() repository.save()
messages.success(self.request, _('Added new remote SSH repository.')) messages.success(self.request,
_('Added new remote SSH repository.'))
url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid]) url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid])
return redirect(url) return redirect(url)
class VerifySshHostkeyView(SuccessMessageMixin, FormView): class VerifySshHostkeyView(FormView):
"""View to verify SSH Hostkey of the remote repository.""" """View to verify SSH Hostkey of the remote repository."""
form_class = forms.VerifySshHostkeyForm form_class = forms.VerifySshHostkeyForm
template_name = 'verify_ssh_hostkey.html' template_name = 'verify_ssh_hostkey.html'
@ -412,6 +442,7 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
"""Create and store the repository.""" """Create and store the repository."""
ssh_public_key = form.cleaned_data['ssh_public_key'] ssh_public_key = form.cleaned_data['ssh_public_key']
with handle_common_errors(self.request):
self._add_ssh_hostkey(ssh_public_key) self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.')) messages.success(self.request, _('SSH host verified.'))
if _save_repository(self.request, self._get_repository()): if _save_repository(self.request, self._get_repository()):
@ -427,11 +458,12 @@ def _save_repository(request, repository):
repository.verified = True repository.verified = True
repository.save() repository.save()
return True return True
except paramiko.BadHostKeyException: except subprocess.CalledProcessError as exception:
if exception.returncode in (6, 7):
message = _('SSH host public key could not be verified.') message = _('SSH host public key could not be verified.')
except paramiko.AuthenticationException: elif exception.returncode == 5:
message = _('Authentication to remote server failed.') message = _('Authentication to remote server failed.')
except paramiko.SSHException as exception: else:
message = _('Error establishing connection to server: {}').format( message = _('Error establishing connection to server: {}').format(
str(exception)) str(exception))
except Exception as exception: except Exception as exception:
@ -450,7 +482,7 @@ def _save_repository(request, repository):
return False return False
class RemoveRepositoryView(SuccessMessageMixin, TemplateView): class RemoveRepositoryView(TemplateView):
"""View to delete a repository.""" """View to delete a repository."""
template_name = 'backups_repository_remove.html' template_name = 'backups_repository_remove.html'
@ -463,14 +495,16 @@ class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
def post(self, request, uuid): def post(self, request, uuid):
"""Delete the repository on confirmation.""" """Delete the repository on confirmation."""
with handle_common_errors(self.request):
repository = get_instance(uuid) repository = get_instance(uuid)
repository.remove() repository.remove()
messages.success(request, messages.success(
_('Repository removed. Backups were not deleted.')) request, _('Repository removed. Backups were not deleted.'))
return redirect('backups:index') return redirect('backups:index')
@require_POST
def umount_repository(request, uuid): def umount_repository(request, uuid):
"""View to unmount a remote SSH repository.""" """View to unmount a remote SSH repository."""
repository = SshBorgRepository.load(uuid) repository = SshBorgRepository.load(uuid)
@ -481,6 +515,7 @@ def umount_repository(request, uuid):
return redirect('backups:index') return redirect('backups:index')
@require_POST
def mount_repository(request, uuid): def mount_repository(request, uuid):
"""View to mount a remote SSH repository.""" """View to mount a remote SSH repository."""
# Do not mount unverified ssh repositories. Prompt for verification. # Do not mount unverified ssh repositories. Prompt for verification.

View File

@ -56,7 +56,6 @@ class BepastyApp(app_module.App):
info = app_module.Info(self.app_id, self._version, name=_('bepasty'), info = app_module.Info(self.app_id, self._version, name=_('bepasty'),
icon_filename='bepasty', icon_filename='bepasty',
short_description=_('File & Snippet Sharing'),
description=_description, manual_page='bepasty', description=_description, manual_page='bepasty',
clients=manifest.clients, tags=manifest.tags) clients=manifest.clients, tags=manifest.tags)
self.add(info) self.add(info)

View File

@ -38,8 +38,8 @@ class BindApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
name=_('BIND'), icon='fa-globe-w', name=_('BIND'), icon='fa-globe-w',
short_description=_('Domain Name Server'), description=_description, manual_page='Bind',
description=_description, manual_page='Bind') tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-bind', info.name, info.short_description, menu_item = menu.Menu('menu-bind', info.name, info.short_description,

View File

@ -3,9 +3,17 @@
Application manifest for bind. Application manifest for bind.
""" """
from django.utils.translation import gettext_lazy as _
backup = { backup = {
'config': { 'config': {
'files': ['/etc/bind/named.conf.options'] 'files': ['/etc/bind/named.conf.options']
}, },
'services': ['named'] 'services': ['named']
} }
tags = [
_('DNS'),
_('Server'),
_('Resolver'),
]

View File

@ -54,7 +54,6 @@ class CalibreApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
name=_('calibre'), icon_filename='calibre', name=_('calibre'), icon_filename='calibre',
short_description=_('E-book Library'),
description=_description, manual_page='Calibre', description=_description, manual_page='Calibre',
clients=manifest.clients, tags=manifest.tags, clients=manifest.clients, tags=manifest.tags,
donation_url='https://calibre-ebook.com/donate') donation_url='https://calibre-ebook.com/donate')

View File

@ -52,9 +52,8 @@ class CockpitApp(app_module.App):
depends=['apache'], is_essential=True, depends=['apache'], is_essential=True,
name=_('Cockpit'), icon='fa-wrench', name=_('Cockpit'), icon='fa-wrench',
icon_filename='cockpit', icon_filename='cockpit',
short_description=_('Server Administration'),
description=_description, manual_page='Cockpit', description=_description, manual_page='Cockpit',
clients=manifest.clients) clients=manifest.clients, tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-cockpit', info.name, menu_item = menu.Menu('menu-cockpit', info.name,

View File

@ -18,3 +18,13 @@ clients = [{
# will set the value of allowed domains correctly. This is the only key the is # will set the value of allowed domains correctly. This is the only key the is
# customized in cockpit.conf. # customized in cockpit.conf.
backup: dict = {} backup: dict = {}
tags = [
_('Advanced administration'),
_('Web terminal'),
_('Storage'),
_('Networking'),
_('Services'),
_('Logs'),
_('Performance'),
]

View File

@ -12,7 +12,7 @@ from plinth.modules.apache import (get_users_with_website, user_of_uws_url,
from plinth.package import Packages from plinth.package import Packages
from plinth.privileged import service as service_privileged from plinth.privileged import service as service_privileged
from . import privileged from . import manifest, privileged
_description = [ _description = [
_('Here you can set some general configuration options ' _('Here you can set some general configuration options '
@ -39,7 +39,7 @@ class ConfigApp(app_module.App):
depends=['apache', 'firewall', 'names' depends=['apache', 'firewall', 'names'
], name=_('General Configuration'), ], name=_('General Configuration'),
icon='fa-cog', description=_description, icon='fa-cog', description=_description,
manual_page='Configure') manual_page='Configure', tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-config', _('Configure'), None, info.icon, menu_item = menu.Menu('menu-config', _('Configure'), None, info.icon,

View File

@ -0,0 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Application manifest for configure.
"""
from django.utils.translation import gettext_lazy as _
tags = [_('Homepage'), _('Logging'), _('Advanced apps')]

View File

@ -50,7 +50,6 @@ class CoturnApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
name=_('Coturn'), icon_filename='coturn', name=_('Coturn'), icon_filename='coturn',
short_description=_('VoIP Helper'),
description=_description, manual_page='Coturn', description=_description, manual_page='Coturn',
tags=manifest.tags) tags=manifest.tags)
self.add(info) self.add(info)

View File

@ -67,7 +67,7 @@ class DateTimeApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
is_essential=True, name=_('Date & Time'), is_essential=True, name=_('Date & Time'),
icon='fa-clock-o', description=_description, icon='fa-clock-o', description=_description,
manual_page='DateTime') manual_page='DateTime', tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-datetime', info.name, None, info.icon, menu_item = menu.Menu('menu-datetime', info.name, None, info.icon,

View File

@ -3,9 +3,13 @@
Application manifest for datetime. Application manifest for datetime.
""" """
from django.utils.translation import gettext_lazy as _
backup = { backup = {
'data': { 'data': {
'files': ['/etc/localtime'] 'files': ['/etc/localtime']
}, },
'services': ['systemd-timedated'], 'services': ['systemd-timedated'],
} }
tags = [_('Network time'), _('Timezone')]

View File

@ -58,10 +58,8 @@ class DelugeApp(app_module.App):
info = app_module.Info( info = app_module.Info(
app_id=self.app_id, version=self._version, name=_('Deluge'), app_id=self.app_id, version=self._version, name=_('Deluge'),
icon_filename='deluge', icon_filename='deluge', description=_description,
short_description=_('BitTorrent Web Client'), manual_page='Deluge', clients=manifest.clients,
description=_description, manual_page='Deluge',
clients=manifest.clients,
donation_url='https://www.patreon.com/deluge_cas', donation_url='https://www.patreon.com/deluge_cas',
tags=manifest.tags) tags=manifest.tags)
self.add(info) self.add(info)

View File

@ -52,7 +52,7 @@ class DiagnosticsApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
is_essential=True, name=_('Diagnostics'), is_essential=True, name=_('Diagnostics'),
icon='fa-heartbeat', description=_description, icon='fa-heartbeat', description=_description,
manual_page='Diagnostics') manual_page='Diagnostics', tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-diagnostics', info.name, None, info.icon, menu_item = menu.Menu('menu-diagnostics', info.name, None, info.icon,

View File

@ -3,4 +3,8 @@
Application manifest for diagnostics. Application manifest for diagnostics.
""" """
from django.utils.translation import gettext_lazy as _
backup: dict = {} backup: dict = {}
tags = [_('Detect problems'), _('Repair'), _('Daily')]

View File

@ -25,16 +25,6 @@
{% if results %} {% if results %}
{% include "diagnostics_results.html" with results=results %} {% include "diagnostics_results.html" with results=results %}
{% elif exception %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Caution:" %}</span>
</div>
<div>
{{ exception }}
</div>
</div>
{% else %} {% else %}
<p>{% trans "This app does not support diagnostics" %}</p> <p>{% trans "This app does not support diagnostics" %}</p>
{% endif %} {% endif %}

View File

@ -103,17 +103,9 @@ def diagnose_app(request, app_id):
app = App.get(app_id) app = App.get(app_id)
except KeyError: except KeyError:
raise Http404('App does not exist') raise Http404('App does not exist')
app_name = app.info.name or app_id app_name = app.info.name or app_id
diagnosis = None
diagnosis_exception = None
try:
diagnosis = app.diagnose() diagnosis = app.diagnose()
except Exception as exception:
logger.exception('Error running %s diagnostics - %s', app_id,
exception)
diagnosis_exception = str(exception)
show_repair = False show_repair = False
for check in diagnosis: for check in diagnosis:
if check.result in [Result.FAILED, Result.WARNING]: if check.result in [Result.FAILED, Result.WARNING]:
@ -126,7 +118,6 @@ def diagnose_app(request, app_id):
'app_id': app_id, 'app_id': app_id,
'app_name': app_name, 'app_name': app_name,
'results': diagnosis, 'results': diagnosis,
'exception': diagnosis_exception,
'show_repair': show_repair, 'show_repair': show_repair,
}) })

View File

@ -60,7 +60,7 @@ class DynamicDNSApp(app_module.App):
is_essential=True, depends=['names'], is_essential=True, depends=['names'],
name=_('Dynamic DNS Client'), icon='fa-refresh', name=_('Dynamic DNS Client'), icon='fa-refresh',
description=_description, description=_description,
manual_page='DynamicDNS') manual_page='DynamicDNS', tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-dynamicdns', info.name, None, info.icon, menu_item = menu.Menu('menu-dynamicdns', info.name, None, info.icon,

View File

@ -1,4 +1,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""
Application manifest for Dynamic DNS.
"""
from django.utils.translation import gettext_lazy as _
backup = { backup = {
'config': { 'config': {
@ -8,3 +13,5 @@ backup = {
'dynamicdns_enable', 'dynamicdns_config', 'dynamicdns_status' 'dynamicdns_enable', 'dynamicdns_config', 'dynamicdns_status'
], ],
} }
tags = [_('Domain'), _('Free'), _('Needs public IP')]

View File

@ -22,58 +22,60 @@
* in this page. * in this page.
*/ */
(function($) { document.addEventListener('DOMContentLoaded', () => {
var NOIP = 'https://<User>:<Pass>@dynupdate.no-ip.com/nic/update?' + const NOIP = 'https://<User>:<Pass>@dynupdate.no-ip.com/nic/update?' +
'hostname=<Domain>'; 'hostname=<Domain>';
var FREEDNS = 'https://freedns.afraid.org/dynamic/update.php?' + const FREEDNS = 'https://freedns.afraid.org/dynamic/update.php?' +
'_YOURAPIKEYHERE_'; '_YOURAPIKEYHERE_';
$('#id_service_type').change(function() { document.getElementById('id_service_type').addEventListener('change', () => {
set_mode(); setMode();
var service_type = $("#id_service_type").val(); const service_type = document.getElementById('id_service_type').value;
if (service_type == "noip.com") { if (service_type === "noip.com") {
$('#id_update_url').val(NOIP); document.getElementById('id_update_url').value = NOIP;
} else if (service_type == "freedns.afraid.org") { } else if (service_type === "freedns.afraid.org") {
$('#id_update_url').val(FREEDNS); document.getElementById('id_update_url').value = FREEDNS;
} else { // GnuDIP and other } else { // GnuDIP and other
$('#id_update_url').val(''); document.getElementById('id_update_url').value = '';
} }
}); });
$('#id_show_password').change(function() { document.getElementById('id_show_password').addEventListener('change', () => {
if ($('#id_show_password').prop('checked')) { if (document.getElementById('id_show_password').checked) {
$('#id_password').prop('type', 'text'); document.getElementById('id_password').type = 'text';
} else { } else {
$('#id_password').prop('type', 'password'); document.getElementById('id_password').type = 'password';
} }
}); });
function set_mode() { function setMode() {
var service_type = $("#id_service_type").val(); const service_type = document.getElementById('id_service_type').value;
if (service_type == "gnudip") { if (service_type === "gnudip") {
set_gnudip_mode(); setGnudipMode();
} else { } else {
set_update_url_mode(); setUpdateUrlMode();
} }
} }
function set_gnudip_mode() { function setGnudipMode() {
$('.form-group').show(); document.querySelectorAll('.form-group').forEach((element) => {
$('#id_update_url').closest('.form-group').hide(); element.style.display = 'block';
$('#id_disable_ssl_cert_check').closest('.form-group').hide(); });
$('#id_use_http_basic_auth').closest('.form-group').hide(); document.getElementById('id_update_url').closest('.form-group').style.display = 'none';
$('#id_use_ipv6').closest('.form-group').hide(); document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'none';
$('#id_server').closest('.form-group').show(); document.getElementById('id_use_http_basic_auth').closest('.form-group').style.display = 'none';
document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'none';
document.getElementById('id_server').closest('.form-group').style.display = 'block';
} }
function set_update_url_mode() { function setUpdateUrlMode() {
$('#id_update_url').closest('.form-group').show(); document.getElementById('id_update_url').closest('.form-group').style.display = 'block';
$('#id_disable_ssl_cert_check').closest('.form-group').show(); document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'block';
$('#id_use_http_basic_auth').closest('.form-group').show(); document.getElementById('id_use_http_basic_auth').closest('.form-group').style.display = 'block';
$('#id_use_ipv6').closest('.form-group').show(); document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'block';
$('#id_server').closest('.form-group').hide(); document.getElementById('id_server').closest('.form-group').style.display = 'none';
} }
set_mode(); setMode();
})(jQuery); });

View File

@ -59,7 +59,6 @@ class EjabberdApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
depends=['coturn'], name=_('ejabberd'), depends=['coturn'], name=_('ejabberd'),
icon_filename='ejabberd', icon_filename='ejabberd',
short_description=_('Chat Server'),
description=_description, description=_description,
manual_page='ejabberd', manual_page='ejabberd',
clients=manifest.clients, tags=manifest.tags) clients=manifest.clients, tags=manifest.tags)

View File

@ -22,14 +22,21 @@
* in this page. * in this page.
*/ */
jQuery(function($) { document.addEventListener('DOMContentLoaded', () => {
$('#id_enable_managed_turn').change(function() { const enableManagedTurn = document.getElementById('id_enable_managed_turn');
if($(this).prop('checked')) { const turnUrisGroup = document.getElementById('id_turn_uris').closest('.form-group');
$('#id_turn_uris').closest('.form-group').hide(); const sharedSecretGroup = document.getElementById('id_shared_secret').closest('.form-group');
$('#id_shared_secret').closest('.form-group').hide();
function toggleVisibility() {
if (enableManagedTurn.checked) {
turnUrisGroup.style.display = 'none';
sharedSecretGroup.style.display = 'none';
} else { } else {
$('#id_turn_uris').closest('.form-group').show(); turnUrisGroup.style.display = '';
$('#id_shared_secret').closest('.form-group').show(); sharedSecretGroup.style.display = '';
} }
}).change(); }
enableManagedTurn.addEventListener('change', toggleVisibility);
toggleVisibility();
}); });

View File

@ -52,7 +52,7 @@ class EmailApp(plinth.app.App):
app_id = 'email' app_id = 'email'
_version = 4 _version = 5
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the email app.""" """Initialize the email app."""
@ -60,10 +60,9 @@ class EmailApp(plinth.app.App):
info = plinth.app.Info(app_id=self.app_id, version=self._version, info = plinth.app.Info(app_id=self.app_id, version=self._version,
name=_('Postfix/Dovecot'), name=_('Postfix/Dovecot'),
icon_filename='email', icon_filename='email', description=_description,
short_description=_('Email Server'), manual_page='Email', clients=manifest.clients,
description=_description, manual_page='Email', tags=manifest.tags,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://rspamd.com/support.html') donation_url='https://rspamd.com/support.html')
self.add(info) self.add(info)
@ -117,6 +116,7 @@ class EmailApp(plinth.app.App):
'/etc/rspamd/local.d/freedombox-logging.inc', '/etc/rspamd/local.d/freedombox-logging.inc',
'/etc/rspamd/local.d/freedombox-milter-headers.conf', '/etc/rspamd/local.d/freedombox-milter-headers.conf',
'/etc/rspamd/local.d/freedombox-redis.conf', '/etc/rspamd/local.d/freedombox-redis.conf',
'/etc/rspamd/local.d/freedombox-dkim-signing.conf'
]) ])
self.add(dropin_configs) self.add(dropin_configs)
dropin_configs_sieve = DropinConfigs( dropin_configs_sieve = DropinConfigs(
@ -220,6 +220,9 @@ class EmailApp(plinth.app.App):
# Expose to public internet # Expose to public internet
if old_version == 0: if old_version == 0:
self.enable() self.enable()
elif old_version < 5:
privileged.fix_incorrect_key_ownership()
service_privileged.try_restart('rspamd')
def _get_first_admin(): def _get_first_admin():

View File

@ -0,0 +1,11 @@
# Do not edit this file. Manage your settings on FreedomBox or make your
# settings changes in a different configuration file.
# Configure how DKIM signatures are made by rspamd on outgoing mail.
# When sending an email with address that is an alias, the username used for
# authentication will not match the email address in 'From' field. rspamd
# refuses to add a DKIM signatures to such outgoing mail. Allow DKIM signatures
# to be made on outgoing mails where the 'From' does not match the authenticated
# user.
allow_username_mismatch = true;

View File

@ -2,13 +2,15 @@
"""Provides privileged actions that run as root.""" """Provides privileged actions that run as root."""
from .aliases import setup_aliases from .aliases import setup_aliases
from .dkim import get_dkim_public_key, setup_dkim from .dkim import (get_dkim_public_key, setup_dkim,
fix_incorrect_key_ownership)
from .domain import set_domains from .domain import set_domains
from .home import setup_home from .home import setup_home
from .postfix import setup_postfix from .postfix import setup_postfix
from .spam import setup_spam from .spam import setup_spam
__all__ = [ __all__ = [
'setup_aliases', 'get_dkim_public_key', 'setup_dkim', 'set_domains', 'setup_aliases', 'get_dkim_public_key', 'setup_dkim',
'setup_home', 'setup_postfix', 'setup_spam' 'fix_incorrect_key_ownership', 'set_domains', 'setup_home',
'setup_postfix', 'setup_spam'
] ]

View File

@ -10,9 +10,12 @@ import shutil
import subprocess import subprocess
from plinth.actions import privileged from plinth.actions import privileged
from plinth.privileged import service as service_privileged
_keys_dir = pathlib.Path('/var/lib/rspamd/dkim/') _keys_dir = pathlib.Path('/var/lib/rspamd/dkim/')
rspamd_user = '_rspamd'
DOMAIN_PART_REGEX = r'^[a-zA-Z0-9]([-a-zA-Z0-9]{,61}[a-zA-Z0-9])?$' DOMAIN_PART_REGEX = r'^[a-zA-Z0-9]([-a-zA-Z0-9]{,61}[a-zA-Z0-9])?$'
@ -40,7 +43,7 @@ def setup_dkim(domain: str):
_keys_dir.mkdir(exist_ok=True) _keys_dir.mkdir(exist_ok=True)
_keys_dir.chmod(0o500) _keys_dir.chmod(0o500)
shutil.chown(_keys_dir, '_rspamd', '_rspamd') shutil.chown(_keys_dir, rspamd_user, rspamd_user)
# Default path is /var/lib/dkim/$domain.$selector.key. Default selector is # Default path is /var/lib/dkim/$domain.$selector.key. Default selector is
# "dkim". Use these to simplify key management until we have a need to # "dkim". Use these to simplify key management until we have a need to
@ -55,4 +58,13 @@ def setup_dkim(domain: str):
'rspamadm', 'dkim_keygen', '-t', 'rsa', '-b', '2048', '-s', 'dkim', 'rspamadm', 'dkim_keygen', '-t', 'rsa', '-b', '2048', '-s', 'dkim',
'-d', domain, '-k', (str(key_file)) '-d', domain, '-k', (str(key_file))
], check=True) ], check=True)
shutil.chown(key_file, rspamd_user, rspamd_user)
key_file.chmod(0o400) key_file.chmod(0o400)
service_privileged.try_restart('rspamd')
@privileged
def fix_incorrect_key_ownership():
"""Set the ownership on DKIM private keys."""
for key in _keys_dir.glob('*.dkim.key'):
shutil.chown(key, rspamd_user, rspamd_user)

View File

@ -4,6 +4,8 @@ Configures rspamd to handle incoming and outgoing spam.
See: http://www.postfix.org/MILTER_README.html See: http://www.postfix.org/MILTER_README.html
See: https://rspamd.com/doc/configuration/ucl.html See: https://rspamd.com/doc/configuration/ucl.html
For testing DKIM signatures: https://www.mail-tester.com/
""" """
import pathlib import pathlib
@ -37,7 +39,8 @@ def _setup_rspamd():
"""Adjust configuration to include FreedomBox configuration files.""" """Adjust configuration to include FreedomBox configuration files."""
configs = [('milter_headers.conf', 'freedombox-milter-headers.conf'), configs = [('milter_headers.conf', 'freedombox-milter-headers.conf'),
('redis.conf', 'freedombox-redis.conf'), ('redis.conf', 'freedombox-redis.conf'),
('logging.inc', 'freedombox-logging.inc')] ('logging.inc', 'freedombox-logging.inc'),
('dkim_signing.conf', 'freedombox-dkim-signing.conf')]
base_path = pathlib.Path('/etc/rspamd/local.d') base_path = pathlib.Path('/etc/rspamd/local.d')
for orig_path, include_path in configs: for orig_path, include_path in configs:
_setup_local_include(base_path / orig_path, base_path / include_path) _setup_local_include(base_path / orig_path, base_path / include_path)

View File

@ -58,7 +58,6 @@ class FeatherWikiApp(app_module.App):
info = app_module.Info(self.app_id, self._version, info = app_module.Info(self.app_id, self._version,
name=_('Feather Wiki'), name=_('Feather Wiki'),
icon_filename='featherwiki', icon_filename='featherwiki',
short_description=_('Personal Notebooks'),
description=_description, description=_description,
manual_page='FeatherWiki', manual_page='FeatherWiki',
clients=manifest.clients, tags=manifest.tags) clients=manifest.clients, tags=manifest.tags)

View File

@ -60,7 +60,7 @@ class FirewallApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version, info = app_module.Info(app_id=self.app_id, version=self._version,
is_essential=True, name=_('Firewall'), is_essential=True, name=_('Firewall'),
icon='fa-shield', description=_description, icon='fa-shield', description=_description,
manual_page='Firewall') manual_page='Firewall', tags=manifest.tags)
self.add(info) self.add(info)
menu_item = menu.Menu('menu-firewall', info.name, None, info.icon, menu_item = menu.Menu('menu-firewall', info.name, None, info.icon,

View File

@ -3,4 +3,8 @@
Application manifest for firewall. Application manifest for firewall.
""" """
from django.utils.translation import gettext_lazy as _
backup: dict = {} backup: dict = {}
tags = [_('Ports'), _('Blocking'), _('Status'), _('Automatic')]

View File

@ -15,6 +15,9 @@ no-brand
{% block notifications_dropdown %} {% block notifications_dropdown %}
{% endblock %} {% endblock %}
{% block breadcrumbs %}
{% endblock %}
{% block mainmenu_toggler %} {% block mainmenu_toggler %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{{ block.super }} {{ block.super }}

View File

@ -6,6 +6,9 @@
{% load bootstrap %} {% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block breadcrumbs %}
{% endblock %}
{% block content %} {% block content %}
<h2>{% trans "Setup Complete! Next Steps:" %}</h2> <h2>{% trans "Setup Complete! Next Steps:" %}</h2>

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