mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
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:
commit
ec0ba5df3c
3
Makefile
3
Makefile
@ -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))
|
||||||
|
|
||||||
|
|||||||
129
debian/changelog
vendored
129
debian/changelog
vendored
@ -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
3
debian/control
vendored
@ -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,
|
||||||
|
|||||||
@ -211,7 +211,6 @@ autodoc_mock_imports = [
|
|||||||
'gi',
|
'gi',
|
||||||
'markupsafe',
|
'markupsafe',
|
||||||
'pam',
|
'pam',
|
||||||
'paramiko',
|
|
||||||
'psutil',
|
'psutil',
|
||||||
'pytest',
|
'pytest',
|
||||||
'requests',
|
'requests',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ===
|
||||||
|
|||||||
@ -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 ===
|
||||||
|
|||||||
@ -3,4 +3,4 @@
|
|||||||
Package init file.
|
Package init file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = '24.25'
|
__version__ = '24.26.1'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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
@ -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',
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')]
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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', '-'],
|
||||||
|
|||||||
@ -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')
|
||||||
|
]
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -23,3 +23,7 @@
|
|||||||
.inline-block {
|
.inline-block {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-operations {
|
||||||
|
width: 12.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'),
|
||||||
|
]
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'),
|
||||||
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
8
plinth/modules/config/manifest.py
Normal file
8
plinth/modules/config/manifest.py
Normal 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')]
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')]
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')]
|
||||||
|
|||||||
@ -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);
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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'
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')]
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user