mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
freedombox Debian release 24.20.1
-----BEGIN PGP SIGNATURE----- iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmb0NhUWHGp2YWxsZXJv eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICAW5EADN692QSHMyDBLLd/sdLkI5RyYa O4U3g9uj1q7rnOsvHraabjtKFFxn1QNFQ/rFljVr/VanTfKdBjmflISkgE2mUxYa klf7ALjJ71CVbcS5RV+vlrNPYhSixSUduAalEpRIE0dIiKlXqfk7BIgbEm4PlsvX phS4mLUJI7hUeb7Xgz5UGua3BGpjFvf53OozY+9B7QnV8kfZ3vbvPHL6bopO5ogv BnZ9KkS6bt8rM/PE7Lu6SLdysGa6e+S7Bhb/BkglbriqgPR0fA5kkMiLz+mnuqzb WGGftqW97DfeZo57KVvykZ+6tqmfOjI+Hk0OCPWOBhFaTq8PE5nLzlnvM47Z3j/i 5oKARF92LJPxpbTbpjio99inhsHJ/hk99OasQ1YnsYWGQ0jcMRSO+ZLn7ez6L4UQ GW32Qa3LPyNeHSr5+xtcIKXTiqx+wkCP0YsORA1LFEeHmTM/iuaTxD4xI35xt030 64Yi/nP7gwWzfOkqgmRAnL6zYrI7POaVz3QJJ7DfNS3RwdU/YYpipWNjfzovEFLe S/oYGG1Y+KBNPUUT3vP2qE7eMI4NOcYO411PSbYa+d6ILERM8uCy8XLWKqPCMWPc lQBV2mJr6Bb75gdk/fWmFXLbM2C7OZE0M43Sssr5OZrEBChsiZxhC5ITou0Iww4X SwbZlullO5x+Pju7gA== =q+xB -----END PGP SIGNATURE----- gpgsig -----BEGIN PGP SIGNATURE----- iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmb355AWHGp2YWxsZXJv eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICPRND/wLyF/YKI1SSWDQnEbBuNOWD7Oa augnI3upFqgeD0kc8USDHPp2fNOX0mMKqTzsRL6jOlQdWa5XpzPQGhGUfrgaNWNp NZM4gepWTjM5Kuzuvf6rLkH5LmVfUYx+0Jn8h+7GICUyjvWqdc8O7n6C8cJtAtd9 kltji8pirT7D2dENzcmeZzGx4K8bcrSuN6GZa9BRDXJnVWSEQ3BtpH0CEIRYCEge dIneULX0DI4rign9PS/4Fv8uV52CZTnG7sg0eLdo+VCM5oDr0kCjUvhueiaxKtse dYTQa66QNoaxRBNdSeJPWM3RRqKUSbqWLQNy7hMWYuc1QgPLNosnrR6Y49k/LJvd tpvA07qVrmea7RK8U/6TW+B4f98x+F41x0pUimiYNZHSi26X28/q/Eir5YBCXuJv 459bXP48z/NwwhHC/KorseV7PpWoyz14MHI4N1dQ20jzJlnDEQVffkVU8Q77u+S+ 2Jv57ViOutbVAKDuboHK3AFuCWXzF9IgdCm/O8gILaiKAmaXwQ7BFMw3urcx42HB YONGqdk+qHlXpgE2NkhKttlB6vTF9I8TxLQa11q9S5qwOmCV6E61SuowHKnoRsnU Xxjr/e3M3aOk+83ROZD8oYIdZv/FIHHlCei5LN/KnlCIbtlN194bDibkyO9RXS0j 7eEDlBjuHJTVX3kI1g== =WVoY -----END PGP SIGNATURE----- Merge tag 'v24.20.1' into debian/bookworm-backports freedombox Debian release 24.20.1 Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
commit
19d0e64cee
@ -136,8 +136,9 @@ lintian:
|
||||
|
||||
# autopkgtest is flaky due to
|
||||
# https://salsa.debian.org/freedombox-team/freedombox/-/issues/2077
|
||||
#autopkgtest:
|
||||
# extends: .test-autopkgtest
|
||||
autopkgtest:
|
||||
extends: .test-autopkgtest
|
||||
allow_failure: true
|
||||
|
||||
blhc:
|
||||
extends: .test-blhc
|
||||
|
||||
5
Makefile
5
Makefile
@ -176,6 +176,11 @@ provision-dev:
|
||||
$$(sudo -u plinth ./run --develop --list-dependencies)
|
||||
apt-mark unhold freedombox
|
||||
|
||||
# DNS resolution may be broken by upgrade to systemd-resolved. See
|
||||
# #1079819 and ##1032937.
|
||||
-systemctl restart systemd-resolved
|
||||
-nmcli general reload dns-rc
|
||||
|
||||
# Install additional packages
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --yes ncurses-term \
|
||||
sshpass bash-completion
|
||||
|
||||
11
container
11
container
@ -916,6 +916,14 @@ def _destroy(distribution):
|
||||
_get_compressed_image_path(distribution))
|
||||
|
||||
|
||||
def _is_privisioned(distribution):
|
||||
"""Return the container has been provisioned fully."""
|
||||
compressed_image = _get_compressed_image_path(distribution)
|
||||
provision_file = compressed_image.with_suffix('.provisioned')
|
||||
if provision_file.exists():
|
||||
return
|
||||
|
||||
|
||||
def _provision(image_file, distribution):
|
||||
"""Run app setup inside the container."""
|
||||
provision_file = image_file.with_suffix(image_file.suffix + '.provisioned')
|
||||
@ -1038,7 +1046,8 @@ def _is_update_required(distribution):
|
||||
def subcommand_up(arguments):
|
||||
"""Download, setup and bring up the container."""
|
||||
machine_name = f'fbx-{arguments.distribution}'
|
||||
if _get_machine_status(machine_name):
|
||||
if _get_machine_status(machine_name) and _is_privisioned(
|
||||
arguments.distribution):
|
||||
logger.info('Container is already running')
|
||||
_print_banner(arguments.distribution)
|
||||
return
|
||||
|
||||
147
debian/changelog
vendored
147
debian/changelog
vendored
@ -1,3 +1,150 @@
|
||||
freedombox (24.20.1) unstable; urgency=medium
|
||||
|
||||
[ Veiko Aasa ]
|
||||
* users: Inactivate users in LDAP user database
|
||||
* samba: Fix nmb systemd service is in erroneous state
|
||||
|
||||
[ Sunil Mohan Adapa ]
|
||||
* users: Set proper class on default password policy object
|
||||
* users: Increment app version for changes w.r.t. inactive users
|
||||
* security: Remove PAM configuration for 'access' module
|
||||
|
||||
[ James Valleroy ]
|
||||
* Revert "debian: tests: Wait for systemd-resolved to be started"
|
||||
* ci: Run autopkgtest but allow failure
|
||||
* d/tests: Add breaks-testbed restriction
|
||||
* doc: Fetch latest manual
|
||||
|
||||
[ gallegonovato ]
|
||||
* Translated using Weblate (Spanish)
|
||||
|
||||
[ Burak Yavuz ]
|
||||
* Translated using Weblate (Turkish)
|
||||
|
||||
[ 大王叫我来巡山 ]
|
||||
* Translated using Weblate (Chinese (Simplified Han script))
|
||||
|
||||
[ 109247019824 ]
|
||||
* Translated using Weblate (Bulgarian)
|
||||
|
||||
-- James Valleroy <jvalleroy@mailbox.org> Wed, 25 Sep 2024 11:57:46 -0400
|
||||
|
||||
freedombox (24.20) unstable; urgency=medium
|
||||
|
||||
[ gallegonovato ]
|
||||
* Translated using Weblate (Spanish)
|
||||
* Translated using Weblate (Spanish)
|
||||
|
||||
[ Burak Yavuz ]
|
||||
* Translated using Weblate (Turkish)
|
||||
|
||||
[ 大王叫我来巡山 ]
|
||||
* Translated using Weblate (Chinese (Simplified Han script))
|
||||
|
||||
[ 109247019824 ]
|
||||
* Translated using Weblate (Bulgarian)
|
||||
|
||||
[ Besnik Bleta ]
|
||||
* Translated using Weblate (Albanian)
|
||||
* Translated using Weblate (Albanian)
|
||||
|
||||
[ Jiří Podhorecký ]
|
||||
* Translated using Weblate (Czech)
|
||||
|
||||
[ James Valleroy ]
|
||||
* upgrades: Treat n/a release as testing
|
||||
* debian: tests: Wait for systemd-resolved to be started
|
||||
* action_utils: Remove extra empty line
|
||||
* locale: Update translation strings
|
||||
* doc: Fetch latest manual
|
||||
|
||||
[ Sunil Mohan Adapa ]
|
||||
* config, names: Move setting hostname from config to names
|
||||
* config, names: Move domain name configuration to names app
|
||||
* tests: functional: Don't timeout when web server restarts
|
||||
* service: Add privileged utility for 'try-reload-or-restart' action
|
||||
* letsencrypt: Allow reloading daemons after cert changes
|
||||
* apache: Don't restart daemon when changing certificates
|
||||
* users: Don't cache NSS user identity information
|
||||
* action_utils: Update outdated docstrings
|
||||
* action_utils: Add a method to reset services in 'failed' state
|
||||
* miniflux: Workaround a packaging bug with DB connection
|
||||
|
||||
[ Veiko Aasa ]
|
||||
* users: Invalidate nscd cache after nslcd service startup
|
||||
|
||||
[ Benedek Nagy ]
|
||||
* nextcloud: Fix issue with upgrading to next version
|
||||
|
||||
[ ikmaak ]
|
||||
* Translated using Weblate (Dutch)
|
||||
|
||||
-- James Valleroy <jvalleroy@mailbox.org> Mon, 23 Sep 2024 20:22:01 -0400
|
||||
|
||||
freedombox (24.19) unstable; urgency=medium
|
||||
|
||||
[ ikmaak ]
|
||||
* Translated using Weblate (Dutch)
|
||||
|
||||
[ Burak Yavuz ]
|
||||
* Translated using Weblate (Turkish)
|
||||
|
||||
[ 大王叫我来巡山 ]
|
||||
* Translated using Weblate (Chinese (Simplified))
|
||||
|
||||
[ 109247019824 ]
|
||||
* Translated using Weblate (Bulgarian)
|
||||
|
||||
[ Besnik Bleta ]
|
||||
* Translated using Weblate (Albanian)
|
||||
|
||||
[ gallegonovato ]
|
||||
* Translated using Weblate (Spanish)
|
||||
|
||||
[ Ihor Hordiichuk ]
|
||||
* Translated using Weblate (Ukrainian)
|
||||
|
||||
[ Ettore Atalan ]
|
||||
* Translated using Weblate (German)
|
||||
|
||||
[ Hemanth Kumar Veeranki ]
|
||||
* Translated using Weblate (Telugu)
|
||||
|
||||
[ James Valleroy ]
|
||||
* storage: Handle grub-pc package not available
|
||||
* upgrades: Add repair step for held packages
|
||||
* letsencrypt: Handle both standard and custom repairs
|
||||
* locale: Update translation strings
|
||||
* doc: Fetch latest manual
|
||||
|
||||
[ Sunil Mohan Adapa ]
|
||||
* names: Use systemd-resolved for DNS resolution
|
||||
* names, network: Re-feed DNS known to network-manager to resolved
|
||||
* privacy: Implement a way to disable fallback DNS servers
|
||||
* privacy: Show notification again so that users see the new setting
|
||||
* makefile: Workaround problems with systemd-resolved package
|
||||
* networks: Declare a need for DHCP/DNS ports to be open in firewall
|
||||
* bind: Don't start a stopped daemon during changes/upgrades
|
||||
* bind: Set default forwarder as systemd-resolved
|
||||
* container: Re-run failed provisioning even if container is running
|
||||
* networks: Groups fields in create/edit connection form
|
||||
* networks: Add support for DNS-over-TLS for individual connections
|
||||
* networks: Add more options for IPv6 configuration method
|
||||
* networks: Set 'auto' as default IPv6 method in new connection form
|
||||
* names: Add option for setting global DNS-over-TLS preference
|
||||
* names: Implement a diagnostic check for checking name resolution
|
||||
* names: Restart instead of reload for systemd-resolved changes
|
||||
* names: Add option for setting global DNSSEC preference
|
||||
* networks: Show current global value of DNS-over-TLS and link to it
|
||||
* names: Show systemd-resolved status in the names page
|
||||
* networks: Fix focusing on network interface field on error
|
||||
* bind: Fix port number clash with 'shared' network connections
|
||||
|
||||
[ Joseph Nuthalapati ]
|
||||
* mediawiki: Increase PHP maximum execution time to 100 seconds
|
||||
|
||||
-- James Valleroy <jvalleroy@mailbox.org> Mon, 09 Sep 2024 21:08:17 -0400
|
||||
|
||||
freedombox (24.18~bpo12+1) bookworm-backports; urgency=medium
|
||||
|
||||
* Rebuild for bookworm-backports.
|
||||
|
||||
4
debian/control
vendored
4
debian/control
vendored
@ -104,8 +104,6 @@ Depends:
|
||||
lsof,
|
||||
netcat-openbsd,
|
||||
network-manager,
|
||||
# Ensure that nscd is installed rather than unscd.
|
||||
nscd (>= 2),
|
||||
ppp,
|
||||
pppoe,
|
||||
python3-apt,
|
||||
@ -178,8 +176,6 @@ Recommends:
|
||||
powermgmt-base,
|
||||
# fuser, pstree and other utilities
|
||||
psmisc,
|
||||
# Manage /etc/resolv.conf
|
||||
resolvconf,
|
||||
# Tool to kill WLAN, Bluetooth and moble broadband
|
||||
rfkill,
|
||||
# Monitor network traffic
|
||||
|
||||
3
debian/tests/control
vendored
3
debian/tests/control
vendored
@ -8,10 +8,11 @@
|
||||
# - Module inititailzation for essential modules
|
||||
#
|
||||
Test-Command: plinth --list-apps 2> /dev/null
|
||||
Restrictions: needs-root
|
||||
Restrictions: needs-root, breaks-testbed
|
||||
|
||||
#
|
||||
# Run unit and integration tests on installed files.
|
||||
#
|
||||
Test-Command: PYTHONPATH='/usr/lib/python3/dist-packages/' py.test-3 -p no:cacheprovider --cov=plinth --cov-report=html:debci/htmlcov --cov-report=term
|
||||
Depends: git, python3-openssl, python3-pytest, python3-pytest-cov, python3-pytest-django, python3-tomli | python3-coverage (<< 6.0), @
|
||||
Restrictions: breaks-testbed
|
||||
|
||||
@ -10,7 +10,44 @@
|
||||
|
||||
== Name Services ==
|
||||
|
||||
Name Services provides an overview of ways the box can be reached from the public Internet: domain name, Tor Onion Service, and Pagekite. For each type of name, it is shown whether the HTTP, HTTPS, and SSH services are enabled or disabled for incoming connections through the given name.
|
||||
Name Services provides an overview of ways the box can be reached from the public Internet: domain name, Tor Onion Service, and Pagekite. For each type of name, it is shown whether the HTTP, HTTPS, and SSH services are enabled or disabled for incoming connections through the given name. It also shows and allows configuring how !FreedomBox performs domain name resolutions.
|
||||
|
||||
=== systemd-resolved ===
|
||||
|
||||
From release 24.19, !FreedomBox uses systemd-resolved as caching DNS resolver and replaces resolvconf for managing DNS server configuration. This improves privacy and security. Newer installations will come with systemd-resolved and older machines will automatically switch after an upgrade to this new release.
|
||||
|
||||
systemd-resolved automatically acquires DNS servers from Network Manager, the default and recommended way to configure networks on !FreedomBox. However, if you are manually managing network configuration by editing /etc/network/interfaces, you will need to ensure that the DNS servers acquired are passed on to systemd-resolved. Otherwise, Fallback DNS servers will be used. See below.
|
||||
|
||||
=== Support for DNS-over-TLS and DNSSEC ===
|
||||
|
||||
systemd-resolved supports DNS-over-TLS. This protocol allows encrypting DNS
|
||||
communication between !FreedomBox and the DNS server if your DNS server
|
||||
(typically provided by your ISP, sometimes a separate service) has support for
|
||||
it. This improves both privacy and security as it makes it harder for
|
||||
intermediaries to see the communication or manipulate it. New settings for
|
||||
enabling DNS-over-TLS are available at the global level (for all network interfaces) in Name Services app and at the per-connection level in the Networks app's connection settings.
|
||||
|
||||
systemd-resolved supports DNSSEC. This standard allows website owners to sign
|
||||
their DNS records allowing clients to authenticate them. This improves security
|
||||
by making it harder to manipulate DNS responses. If your DNS server supports
|
||||
this feature, it can be turned on. New setting for enabling DNSSEC is available
|
||||
in the Name Services app.
|
||||
|
||||
You can detect whether your current DNS supports DNS-over-TLS and DNSSEC by turning them on in the settings one at a time and running the diagnostics for the Names app. There is a diagnostic check which detects whether you can successfully resolve the domain name deb.debian.org.
|
||||
|
||||
=== Setting a custom DNS server ===
|
||||
|
||||
If your current DNS server provided by your ISP does not support DNS-over-TLS or DNSSEC
|
||||
features, is censoring some domains names, or if you don't trust them enough,
|
||||
you can instead use one of the publicly available DNS servers. This can be done by
|
||||
editing network connections in the Networks app and adding DNS servers manually.
|
||||
You will need to deactivate and re-activate the network connection (or restart
|
||||
!FreedomBox) for the settings to become active. After this, Names app will show you the
|
||||
currently configured DNS servers.
|
||||
|
||||
=== Fallback DNS servers ===
|
||||
|
||||
In some cases, when internet connection is available to the system by no DNS servers are known to systemd-resolved, the fallback DNS servers are used. This may happen, for example, due to misconfiguration when manually managing network configuration instead of using !FreedomBox's default, the Network Manager. These fallback DNS servers, as defaulted by the upstream systemd project, include servers from Cloudflare and Google DNS servers. This has privacy implications but we felt that it was important to avoid !FreedomBox from becoming unreachable due to misconfiguration. It was a difficult decision. Once you have proper DNS configuration and you know that it works, you can turn off fallback DNS servers using a new setting in the Privacy app. There is also a renewed notification in the web interface that will attract your attention towards this. You may also edit the list of Fallback DNS servers by creating a configuration file for systemd-resolved. See [[https://www.freedesktop.org/software/systemd/man/latest/systemd-resolved.html|systemd-resolved documentation]].
|
||||
|
||||
## END_INCLUDE
|
||||
|
||||
|
||||
@ -8,6 +8,80 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
|
||||
|
||||
The following are the release notes for each !FreedomBox version.
|
||||
|
||||
== FreedomBox 24.20.1 (2024-09-25) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* samba: Fix nmb systemd service is in erroneous state
|
||||
* users: Inactivate users in LDAP user database
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* Revert "debian: tests: Wait for systemd-resolved to be started"
|
||||
* ci: Run autopkgtest but allow failure
|
||||
* d/tests: Add breaks-testbed restriction
|
||||
* locale: Update translations for Bulgarian, Chinese (Simplified Han script), Spanish, Turkish
|
||||
* security: Remove PAM configuration for 'access' module
|
||||
* users: Increment app version for changes w.r.t. inactive users
|
||||
* users: Set proper class on default password policy object
|
||||
|
||||
== FreedomBox 24.20 (2024-09-23) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* nextcloud: Fix issue with upgrading to next version
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* action_utils: Add a method to reset services in 'failed' state
|
||||
* action_utils: Update outdated docstrings
|
||||
* apache: Don't restart daemon when changing certificates
|
||||
* config, names: Move domain name configuration to names app
|
||||
* config, names: Move setting hostname from config to names
|
||||
* debian: tests: Wait for systemd-resolved to be started
|
||||
* letsencrypt: Allow reloading daemons after cert changes
|
||||
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, Dutch, Spanish, Turkish
|
||||
* miniflux: Workaround a packaging bug with DB connection
|
||||
* service: Add privileged utility for 'try-reload-or-restart' action
|
||||
* tests: functional: Don't timeout when web server restarts
|
||||
* upgrades: Treat n/a release as testing
|
||||
* users: Don't cache NSS user identity information
|
||||
* users: Invalidate nscd cache after nslcd service startup
|
||||
|
||||
== FreedomBox 24.19 (2024-09-09) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* mediawiki: Increase PHP maximum execution time to 100 seconds
|
||||
* names: Use systemd-resolved for DNS resolution
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* bind: Don't start a stopped daemon during changes/upgrades
|
||||
* bind: Fix port number clash with 'shared' network connections
|
||||
* bind: Set default forwarder as systemd-resolved
|
||||
* container: Re-run failed provisioning even if container is running
|
||||
* letsencrypt: Handle both standard and custom repairs
|
||||
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified), Dutch, German, Spanish, Telugu, Turkish, Ukrainian
|
||||
* makefile: Workaround problems with systemd-resolved package
|
||||
* names, network: Re-feed DNS known to network-manager to resolved
|
||||
* names: Add option for setting global DNS-over-TLS preference
|
||||
* names: Add option for setting global DNSSEC preference
|
||||
* names: Implement a diagnostic check for checking name resolution
|
||||
* names: Restart instead of reload for systemd-resolved changes
|
||||
* names: Show systemd-resolved status in the names page
|
||||
* networks: Add more options for IPv6 configuration method
|
||||
* networks: Add support for DNS-over-TLS for individual connections
|
||||
* networks: Declare a need for DHCP/DNS ports to be open in firewall
|
||||
* networks: Fix focusing on network interface field on error
|
||||
* networks: Groups fields in create/edit connection form
|
||||
* networks: Set 'auto' as default IPv6 method in new connection form
|
||||
* networks: Show current global value of DNS-over-TLS and link to it
|
||||
* privacy: Implement a way to disable fallback DNS servers
|
||||
* privacy: Show notification again so that users see the new setting
|
||||
* storage: Handle grub-pc package not available
|
||||
* upgrades: Add repair step for held packages
|
||||
|
||||
== FreedomBox 24.18 (2024-08-26) ==
|
||||
|
||||
* *.md, pyproject.toml: Update default branch from 'master' to 'main'
|
||||
|
||||
@ -48,6 +48,25 @@ Upgrading !TiddlyWiki is a manual process.
|
||||
|
||||
If you ever lose a !TiddlyWiki file, you can always retrieve a slightly outdated copy from the Backups app. It is better to keep your local copy after the upgrade, in case you want to revert.
|
||||
|
||||
=== Tips ===
|
||||
|
||||
==== Setting a favicon ====
|
||||
|
||||
Unlike Feather Wiki, !TiddlyWiki does not automatically use your !FreedomBox's favicon. To set it, follow these steps:
|
||||
1. Create a new tiddler with the title `$:/favicon.ico`. Leave the content section empty
|
||||
2. In the `Type` dropdown, select `ICO icon (image/x-icon)`
|
||||
3. In the `Add a new field:` section, set the first field to `_canonical_uri` and the second field to `https://<your-freedombox-url>/favicon.ico`
|
||||
4. Save the new tiddler and the wiki
|
||||
|
||||
'''Reference''': [[https://tiddlywiki.com/static/Setting%2520a%2520favicon.html|Setting a favicon: TiddlyWiki]]
|
||||
|
||||
You can also have a custom image as the favicon for each !TiddlyWiki. Using a distinct favicon makes it easier to identify the tab in your browser. Let's say your !TiddlyWiki file is your personal journal and you want to set the favicon to the image "notebook.png" which looks like a diary
|
||||
1. Open the folder the image is in, using your desktop file explorer
|
||||
2. Drag and drop the file into your !TiddlyWiki's browser tab
|
||||
3. A special tiddler called `$:/import` will be opened
|
||||
4. In the special tiddler, you will have an option to rename the file. Set the file name to `$:/favicon.ico`
|
||||
5. Click the `Import` button and save the wiki
|
||||
|
||||
=== External links ===
|
||||
|
||||
* Website: https://tiddlywiki.com
|
||||
|
||||
@ -9,7 +9,42 @@
|
||||
|
||||
== Servicios de Nombre ==
|
||||
|
||||
Los Servicios de Nombre proporcionan una vista general a las formas de acceder desde la Internet pública a tu !Freedombox: nombre de dominio, servicio ''Tor Onion'' y cometa (''Pagekite''). Para cada tipo de nombre se indica si los servicios HTTP, HTTPS, y SSH están habilitados o deshabilitados para conexiones entrantes.
|
||||
Los Servicios de Nombre proporcionan una vista general a las formas de acceder desde la Internet pública a tu !Freedombox: nombre de dominio, servicio ''Tor Onion'' y cometa (''Pagekite''). Para cada tipo de nombre se indica si los servicios HTTP, HTTPS, y SSH están habilitados o deshabilitados para conexiones entrantes.También muestra y permite configurar como !FreedomBox resuelve los nombres de dominio.
|
||||
|
||||
=== systemd-resolved ===
|
||||
|
||||
Desde la versión 24.19, !FreedomBox emplea `systemd-resolved` como resolutor DNS con memoria y reemplaza a `resolvconf` para administrar la configuración del servidor DNS. Esto mejora la privacidad y la seguridad. Las instalaciones nuevas vendrán con `systemd-resolved` de serie y las anteriores cambiarán automáticamente tras una actualización a esta nueava versión.
|
||||
|
||||
`systemd-resolved` obtiene servidores DNS automáticamente de `Network Manager`, la forma recomendada y por omisión de configurar redes en !FreedomBox. No obstante, si administras la configuración de tu red editando a mano `/etc/network/interfaces`, tendrás que asegurar que los servidores DNS le lleguen a `systemd-resolved`. Si no, se usarán los servidores DNS de último recurso. Ver más abajo.
|
||||
|
||||
=== Soporte para DNS-sobre-TLS y DNSSEC ===
|
||||
|
||||
`systemd-resolved` soporta DNS-sobre-TLS. Este protocolo permite cifrar la comunicación entre !FreedomBox y el servidor de DNS server (habitualmente proporcionado por su proveedor de internet), si este lo soporta.
|
||||
Esto mejora la privacidad y la seguridad porque complica a posibles intermediarios ver o maniplar la comunicación.
|
||||
Los ajustes para habilitar DNS-sobre-TLS están disponibles a nivel global (para todas las interfaces de red) en la aplicación _Servicios de Nombre_ y a nivel de conexión en los ajusted de conexion de la aplicación _Redes_.
|
||||
|
||||
`systemd-resolved` soporta `DNSSEC`. Este estándar permite a los dueños de sitios web firmar sus registros DNS, permitiendo así a los clientes autenticarlos. Esto mejora la seguridad al complicar la manipulación de respuestas DNS.
|
||||
Si tu servidor DNS soporta esta funcionalidad se puede habilitar. Hay un nuevo ajuste para activar `DNSSEC` disponible en la aplicación _Servicios de Nombre_.
|
||||
|
||||
Puedes detectar si tu DNS actual soporta `DNS-over-TLS` y `DNSSEC` si habilitas ambos de uno en uno y ejecutas los diagnósticos de la aplicación de _Nombres_. Hay un diagnostico que detecta si puedes resolver con éxito el nombre de dominio `deb.debian.org`.
|
||||
|
||||
=== Configurar un servidor DNS personalizado ===
|
||||
|
||||
Si el proveedor de servidor DNS que te pone actualmente tu proveedor de internet no soporta las funcionalidades `DNS-sobre-TLS` o `DNSSEC`, censura algunos nombres de domino, o no confías en él lo suficiente,
|
||||
puedes usar un servidor DNS de los públicamente disponibles. Esto se hace editando las conexiones de red en la aplicación de _Redes_ y añadiendo los servidores DNS a mano.
|
||||
Para que los ajustes tengan efecto necesitarás reiniciar tu conexión de red desactivándola y activándola a continuación (o reiniciando !FreedomBox).
|
||||
Después de esto la aplicación _Nombres_ te mostrará los servidores DNS configurados.
|
||||
|
||||
=== Servidores DNS de último recurso ===
|
||||
|
||||
En algunos casos, cuando la conexón a internet está disponoble pero `systemd-resolved` no conoce los servidores DNS, se usan los de último recurso.
|
||||
Esto podría pasar, por ejemplo, debido a una configuración errónea o con configuraciones de red administradas manualmente.
|
||||
Los servidores DNS de último recurso que configura por omisión el proyecto `systemd` incluyen servidores de Cloudflare y de Google.
|
||||
Esto fué una decisión difícil porque conlleva riesgos de privacidad pero pensamos que era importante evitar que !FreedomBox quede inaccesible por un fallo de configuración.
|
||||
Una vez que tienes una configuración DNS funcional puedes esactivar los servidores DNS de último recurso usando el nuevo ajusta de la aplicación de _Privacidad_.
|
||||
En el interfaz web hay también una nueva notificación que atraerá tu atención a este asunto.
|
||||
También puedes editar la lista de servidores DNS de último recurso creando un archivo de configuración para `systemd-resolved`.
|
||||
Mira [[https://www.freedesktop.org/software/systemd/man/latest/systemd-resolved.html|la documentación de systemd-resolved]].
|
||||
|
||||
## END_INCLUDE
|
||||
|
||||
|
||||
@ -8,6 +8,80 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
|
||||
|
||||
The following are the release notes for each !FreedomBox version.
|
||||
|
||||
== FreedomBox 24.20.1 (2024-09-25) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* samba: Fix nmb systemd service is in erroneous state
|
||||
* users: Inactivate users in LDAP user database
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* Revert "debian: tests: Wait for systemd-resolved to be started"
|
||||
* ci: Run autopkgtest but allow failure
|
||||
* d/tests: Add breaks-testbed restriction
|
||||
* locale: Update translations for Bulgarian, Chinese (Simplified Han script), Spanish, Turkish
|
||||
* security: Remove PAM configuration for 'access' module
|
||||
* users: Increment app version for changes w.r.t. inactive users
|
||||
* users: Set proper class on default password policy object
|
||||
|
||||
== FreedomBox 24.20 (2024-09-23) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* nextcloud: Fix issue with upgrading to next version
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* action_utils: Add a method to reset services in 'failed' state
|
||||
* action_utils: Update outdated docstrings
|
||||
* apache: Don't restart daemon when changing certificates
|
||||
* config, names: Move domain name configuration to names app
|
||||
* config, names: Move setting hostname from config to names
|
||||
* debian: tests: Wait for systemd-resolved to be started
|
||||
* letsencrypt: Allow reloading daemons after cert changes
|
||||
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified Han script), Czech, Dutch, Spanish, Turkish
|
||||
* miniflux: Workaround a packaging bug with DB connection
|
||||
* service: Add privileged utility for 'try-reload-or-restart' action
|
||||
* tests: functional: Don't timeout when web server restarts
|
||||
* upgrades: Treat n/a release as testing
|
||||
* users: Don't cache NSS user identity information
|
||||
* users: Invalidate nscd cache after nslcd service startup
|
||||
|
||||
== FreedomBox 24.19 (2024-09-09) ==
|
||||
|
||||
=== Highlights ===
|
||||
|
||||
* mediawiki: Increase PHP maximum execution time to 100 seconds
|
||||
* names: Use systemd-resolved for DNS resolution
|
||||
|
||||
=== Other Changes ===
|
||||
|
||||
* bind: Don't start a stopped daemon during changes/upgrades
|
||||
* bind: Fix port number clash with 'shared' network connections
|
||||
* bind: Set default forwarder as systemd-resolved
|
||||
* container: Re-run failed provisioning even if container is running
|
||||
* letsencrypt: Handle both standard and custom repairs
|
||||
* locale: Update translations for Albanian, Bulgarian, Chinese (Simplified), Dutch, German, Spanish, Telugu, Turkish, Ukrainian
|
||||
* makefile: Workaround problems with systemd-resolved package
|
||||
* names, network: Re-feed DNS known to network-manager to resolved
|
||||
* names: Add option for setting global DNS-over-TLS preference
|
||||
* names: Add option for setting global DNSSEC preference
|
||||
* names: Implement a diagnostic check for checking name resolution
|
||||
* names: Restart instead of reload for systemd-resolved changes
|
||||
* names: Show systemd-resolved status in the names page
|
||||
* networks: Add more options for IPv6 configuration method
|
||||
* networks: Add support for DNS-over-TLS for individual connections
|
||||
* networks: Declare a need for DHCP/DNS ports to be open in firewall
|
||||
* networks: Fix focusing on network interface field on error
|
||||
* networks: Groups fields in create/edit connection form
|
||||
* networks: Set 'auto' as default IPv6 method in new connection form
|
||||
* networks: Show current global value of DNS-over-TLS and link to it
|
||||
* privacy: Implement a way to disable fallback DNS servers
|
||||
* privacy: Show notification again so that users see the new setting
|
||||
* storage: Handle grub-pc package not available
|
||||
* upgrades: Add repair step for held packages
|
||||
|
||||
== FreedomBox 24.18 (2024-08-26) ==
|
||||
|
||||
* *.md, pyproject.toml: Update default branch from 'master' to 'main'
|
||||
|
||||
@ -54,6 +54,25 @@ Actualizar !TiddlyWiki es un proceso manual.
|
||||
|
||||
Si alguna vez pierdes un archivo !TiddlyWiki puedes recuperar una copia ligaramente anticuada de la app de Copias de Respaldo. Es mejor conservar tu copia local tras la actualización, si quieres revertir.
|
||||
|
||||
=== Consejos ===
|
||||
|
||||
==== Establecer un favicon ====
|
||||
|
||||
A diferencia de Feather Wiki, !TiddlyWiki no usa automaticamente el favicon de tu !FreedomBox's. Para configurarlo sigue estos pasos:
|
||||
1. Crea un tiddler nuevo con el títluo `$:/favicon.ico`. Deja vacía la sección de contenido.
|
||||
2. En el desplegable `Tipo` selecciona `icono ICO (imagen/icono-x)`.
|
||||
3. En la sección `Añadir un campo nuevo:` pon el primer campo a `_canonical_uri` y el segundo a `https://<la-ulr-de-tu--freedombox>/favicon.ico`.
|
||||
4. Graba el tiddler nuevo y el wiki.
|
||||
|
||||
'''Ver''': [[https://tiddlywiki.com/static/Setting%2520a%2520favicon.html|Establecer un favicon: TiddlyWiki]]
|
||||
|
||||
Tambiñen pueder user una imagen como favicon para cada !TiddlyWiki. Usar favicons diferentes facilita identificar la pestaña en el navegador. Pongamos que el archivo !TiddlyWiki es tu diario personal y como favicon quieres ponerle la imagen "notebook.png" que aparenta un diario.
|
||||
1. Abre la carpeta donde esté la imagen mediante el explorador de archivos de tu escritorio.
|
||||
2. Arrastrala a la pestaña del navegador de !TiddlyWiki.
|
||||
3. Se abrirá un tiddler especial llamado `$:/import`.
|
||||
4. En este tiddler tendrás la opción de renombrar el archivo. Renómbralo a `$:/favicon.ico`.
|
||||
5. Haz clic en el botón `Importar` y graba el wiki.
|
||||
|
||||
=== Enlaces externos ===
|
||||
|
||||
* Proyecto original: https://tiddlywiki.com
|
||||
|
||||
@ -3,4 +3,4 @@
|
||||
Package init file.
|
||||
"""
|
||||
|
||||
__version__ = '24.18'
|
||||
__version__ = '24.20.1'
|
||||
|
||||
@ -86,13 +86,13 @@ def service_is_enabled(service_name, strict_check=False):
|
||||
|
||||
|
||||
def service_enable(service_name):
|
||||
"""Enable and start a service in systemd and sysvinit using update-rc.d."""
|
||||
"""Enable and start a service in systemd."""
|
||||
subprocess.call(['systemctl', 'enable', service_name])
|
||||
service_start(service_name)
|
||||
|
||||
|
||||
def service_disable(service_name):
|
||||
"""Disable and stop service in systemd and sysvinit using update-rc.d."""
|
||||
"""Disable and stop service in systemd."""
|
||||
subprocess.call(['systemctl', 'disable', service_name])
|
||||
try:
|
||||
service_stop(service_name)
|
||||
@ -111,30 +111,43 @@ def service_unmask(service_name):
|
||||
|
||||
|
||||
def service_start(service_name):
|
||||
"""Start a service with systemd or sysvinit."""
|
||||
"""Start a service with systemd."""
|
||||
service_action(service_name, 'start')
|
||||
|
||||
|
||||
def service_stop(service_name):
|
||||
"""Stop a service with systemd or sysvinit."""
|
||||
"""Stop a service with systemd."""
|
||||
service_action(service_name, 'stop')
|
||||
|
||||
|
||||
def service_restart(service_name):
|
||||
"""Restart a service with systemd or sysvinit."""
|
||||
"""Restart a service with systemd."""
|
||||
service_action(service_name, 'restart')
|
||||
|
||||
|
||||
def service_try_restart(service_name):
|
||||
"""Try to restart a service with systemd or sysvinit."""
|
||||
"""Try to restart a service with systemd."""
|
||||
service_action(service_name, 'try-restart')
|
||||
|
||||
|
||||
def service_reload(service_name):
|
||||
"""Reload a service with systemd or sysvinit."""
|
||||
"""Reload a service with systemd."""
|
||||
service_action(service_name, 'reload')
|
||||
|
||||
|
||||
def service_try_reload_or_restart(service_name):
|
||||
"""Reload a service if it supports reloading, otherwise restart.
|
||||
|
||||
Do nothing if service is not running.
|
||||
"""
|
||||
service_action(service_name, 'try-reload-or-restart')
|
||||
|
||||
|
||||
def service_reset_failed(service_name):
|
||||
"""Reset the 'failed' state of units."""
|
||||
service_action(service_name, 'reset-failed')
|
||||
|
||||
|
||||
def service_action(service_name, action):
|
||||
"""Perform the given action on the service_name."""
|
||||
subprocess.run(['systemctl', action, service_name],
|
||||
|
||||
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
@ -55,7 +55,7 @@ class ApacheApp(app_module.App):
|
||||
self.add(freedombox_ports)
|
||||
|
||||
letsencrypt = LetsEncrypt('letsencrypt-apache', domains='*',
|
||||
daemons=['apache2'])
|
||||
daemons=['apache2'], reload_daemons=True)
|
||||
self.add(letsencrypt)
|
||||
|
||||
daemon = Daemon('daemon-apache', 'apache2')
|
||||
|
||||
@ -7,8 +7,8 @@ from plinth import app as app_module
|
||||
from plinth import cfg, menu
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.config import get_hostname
|
||||
from plinth.modules.firewall.components import Firewall
|
||||
from plinth.modules.names import get_hostname
|
||||
from plinth.modules.names.components import DomainType
|
||||
from plinth.package import Packages
|
||||
from plinth.privileged import service as service_privileged
|
||||
@ -58,7 +58,7 @@ class AvahiApp(app_module.App):
|
||||
self.add(packages)
|
||||
|
||||
domain_type = DomainType('domain-type-local',
|
||||
_('Local Network Domain'), 'config:index',
|
||||
_('Local Network Domain'), 'names:hostname',
|
||||
can_have_certificate=False)
|
||||
self.add(domain_type)
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ class BindApp(app_module.App):
|
||||
|
||||
app_id = 'bind'
|
||||
|
||||
_version = 3
|
||||
_version = 4
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Configuration helper for BIND server."""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
@ -18,20 +19,24 @@ acl goodclients {
|
||||
localnets;
|
||||
};
|
||||
options {
|
||||
listen-on { !10.42.0.1; !10.42.1.1; !10.42.2.1; !10.42.3.1; !10.42.4.1; !10.42.5.1; !10.42.6.1; !10.42.7.1; any; };
|
||||
directory "/var/cache/bind";
|
||||
|
||||
recursion yes;
|
||||
allow-query { goodclients; };
|
||||
|
||||
forwarders {
|
||||
|
||||
127.0.0.53;
|
||||
};
|
||||
forward first;
|
||||
|
||||
auth-nxdomain no; # conform to RFC1035
|
||||
listen-on-v6 { any; };
|
||||
};
|
||||
'''
|
||||
''' # noqa: E501
|
||||
DEFAULT_FORWARDER = '127.0.0.53' # systemd-resolved
|
||||
LISTEN_ON = 'listen-on { !10.42.0.1; !10.42.1.1; !10.42.2.1; !10.42.3.1; '\
|
||||
'!10.42.4.1; !10.42.5.1; !10.42.6.1; !10.42.7.1; any; };'
|
||||
|
||||
|
||||
@privileged
|
||||
@ -40,19 +45,26 @@ def setup(old_version: int):
|
||||
if old_version == 0:
|
||||
with open(CONFIG_FILE, 'w', encoding='utf-8') as conf_file:
|
||||
conf_file.write(DEFAULT_CONFIG)
|
||||
elif old_version < 3:
|
||||
_remove_dnssec()
|
||||
elif old_version < 4:
|
||||
if not get_config()['forwarders']:
|
||||
_set_forwarders(DEFAULT_FORWARDER)
|
||||
|
||||
if not _has_listen_on():
|
||||
_insert_listen_on()
|
||||
|
||||
if old_version < 3:
|
||||
_remove_dnssec()
|
||||
|
||||
Path(ZONES_DIR).mkdir(exist_ok=True, parents=True)
|
||||
|
||||
action_utils.service_restart('named')
|
||||
action_utils.service_try_restart('named')
|
||||
|
||||
|
||||
@privileged
|
||||
def configure(forwarders: str):
|
||||
"""Configure BIND."""
|
||||
_set_forwarders(forwarders)
|
||||
action_utils.service_restart('named')
|
||||
action_utils.service_try_restart('named')
|
||||
|
||||
|
||||
def get_config():
|
||||
@ -101,6 +113,26 @@ def _remove_dnssec():
|
||||
file_handle.write(line + '\n')
|
||||
|
||||
|
||||
def _has_listen_on():
|
||||
"""Return whether listen-on config option is present."""
|
||||
lines = pathlib.Path(CONFIG_FILE).read_text().splitlines()
|
||||
regex = r'^\s*listen-on\s+{'
|
||||
return any((re.match(regex, line) for line in lines))
|
||||
|
||||
|
||||
def _insert_listen_on():
|
||||
"""Insert the listen-on option."""
|
||||
config_file = pathlib.Path(CONFIG_FILE)
|
||||
lines = config_file.read_text().splitlines(keepends=True)
|
||||
write_lines = []
|
||||
for line in lines:
|
||||
write_lines += line
|
||||
if re.match(r'^\s*options\s+{', line):
|
||||
write_lines += LISTEN_ON + '\n'
|
||||
|
||||
config_file.write_text(''.join(write_lines))
|
||||
|
||||
|
||||
def get_served_domains():
|
||||
"""Return list of domains service handles.
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""FreedomBox app for basic system configuration."""
|
||||
|
||||
import socket
|
||||
|
||||
import augeas
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -11,16 +9,14 @@ from plinth import frontpage, menu
|
||||
from plinth.daemon import RelatedDaemon
|
||||
from plinth.modules.apache import (get_users_with_website, user_of_uws_url,
|
||||
uws_url_of_user)
|
||||
from plinth.modules.names.components import DomainType
|
||||
from plinth.package import Packages
|
||||
from plinth.privileged import service as service_privileged
|
||||
from plinth.signals import domain_added
|
||||
|
||||
from . import privileged
|
||||
|
||||
_description = [
|
||||
_('Here you can set some general configuration options '
|
||||
'like hostname, domain name, webserver home page etc.')
|
||||
'like webserver home page etc.')
|
||||
]
|
||||
|
||||
ADVANCED_MODE_KEY = 'advanced_mode'
|
||||
@ -60,20 +56,6 @@ class ConfigApp(app_module.App):
|
||||
daemon2 = RelatedDaemon('related-daemon-config2', 'rsyslog')
|
||||
self.add(daemon2)
|
||||
|
||||
domain_type = DomainType('domain-type-static', _('Domain Name'),
|
||||
'config:index', can_have_certificate=True)
|
||||
self.add(domain_type)
|
||||
|
||||
@staticmethod
|
||||
def post_init():
|
||||
"""Perform post initialization operations."""
|
||||
# Register domain with Name Services module.
|
||||
domainname = get_domainname()
|
||||
if domainname:
|
||||
domain_added.send_robust(sender='config',
|
||||
domain_type='domain-type-static',
|
||||
name=domainname, services='__all__')
|
||||
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
super().setup(old_version)
|
||||
@ -95,17 +77,6 @@ class ConfigApp(app_module.App):
|
||||
service_privileged.mask('rsyslog')
|
||||
|
||||
|
||||
def get_domainname():
|
||||
"""Return the domainname."""
|
||||
fqdn = socket.getfqdn()
|
||||
return '.'.join(fqdn.split('.')[1:])
|
||||
|
||||
|
||||
def get_hostname():
|
||||
"""Return the hostname."""
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def home_page_url2scid(url):
|
||||
"""Return the shortcut ID of the given home page url."""
|
||||
# url is None when the freedombox-apache-homepage configuration file does
|
||||
|
||||
@ -3,12 +3,7 @@
|
||||
Forms for basic system configuration
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
@ -18,17 +13,6 @@ from plinth.utils import format_lazy
|
||||
|
||||
from . import home_page_url2scid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOSTNAME_REGEX = r'^[a-zA-Z0-9]([-a-zA-Z0-9]{,61}[a-zA-Z0-9])?$'
|
||||
|
||||
|
||||
def domain_label_validator(domainname):
|
||||
"""Validate domain name labels."""
|
||||
for label in domainname.split('.'):
|
||||
if not re.match(HOSTNAME_REGEX, label):
|
||||
raise ValidationError(_('Invalid domain name'))
|
||||
|
||||
|
||||
def get_homepage_choices():
|
||||
"""Return list of drop down choices for home page."""
|
||||
@ -46,40 +30,6 @@ def get_homepage_choices():
|
||||
|
||||
class ConfigurationForm(forms.Form):
|
||||
"""Main system configuration form"""
|
||||
# See:
|
||||
# https://tools.ietf.org/html/rfc952
|
||||
# https://tools.ietf.org/html/rfc1035#section-2.3.1
|
||||
# https://tools.ietf.org/html/rfc1123#section-2
|
||||
# https://tools.ietf.org/html/rfc2181#section-11
|
||||
hostname = forms.CharField(
|
||||
label=gettext_lazy('Hostname'), help_text=format_lazy(
|
||||
gettext_lazy(
|
||||
'Hostname is the local name by which other devices on the '
|
||||
'local network can reach your {box_name}. It must start and '
|
||||
'end with an alphabet or a digit and have as interior '
|
||||
'characters only alphabets, digits and hyphens. Total '
|
||||
'length must be 63 characters or less.'),
|
||||
box_name=gettext_lazy(cfg.box_name)), validators=[
|
||||
validators.RegexValidator(HOSTNAME_REGEX,
|
||||
gettext_lazy('Invalid hostname'))
|
||||
], strip=True)
|
||||
|
||||
domainname = forms.CharField(
|
||||
label=gettext_lazy('Domain Name'), help_text=format_lazy(
|
||||
gettext_lazy(
|
||||
'Domain name is the global name by which other devices on the '
|
||||
'Internet can reach your {box_name}. It must consist of '
|
||||
'labels separated by dots. Each label must start and end '
|
||||
'with an alphabet or a digit and have as interior characters '
|
||||
'only alphabets, digits and hyphens. Length of each label '
|
||||
'must be 63 characters or less. Total length of domain name '
|
||||
'must be 253 characters or less.'),
|
||||
box_name=gettext_lazy(cfg.box_name)), required=False, validators=[
|
||||
validators.RegexValidator(
|
||||
r'^[a-zA-Z0-9]([-a-zA-Z0-9.]{,251}[a-zA-Z0-9])?$',
|
||||
gettext_lazy('Invalid domain name')),
|
||||
domain_label_validator
|
||||
], strip=True)
|
||||
|
||||
homepage = forms.ChoiceField(
|
||||
label=gettext_lazy('Webserver Home Page'), help_text=format_lazy(
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
import augeas
|
||||
|
||||
@ -18,41 +17,6 @@ APACHE_HOMEPAGE_CONFIG = os.path.join(APACHE_CONF_ENABLED_DIR,
|
||||
JOURNALD_FILE = pathlib.Path('/etc/systemd/journald.conf.d/50-freedombox.conf')
|
||||
|
||||
|
||||
@privileged
|
||||
def set_hostname(hostname: str):
|
||||
"""Set system hostname using hostnamectl."""
|
||||
subprocess.run(
|
||||
['hostnamectl', 'set-hostname', '--transient', '--static', hostname],
|
||||
check=True)
|
||||
action_utils.service_restart('avahi-daemon')
|
||||
|
||||
|
||||
@privileged
|
||||
def set_domainname(domainname: str | None = None):
|
||||
"""Set system domainname in /etc/hosts."""
|
||||
hostname = subprocess.check_output(['hostname']).decode().strip()
|
||||
hosts_path = pathlib.Path('/etc/hosts')
|
||||
if domainname:
|
||||
insert_line = f'127.0.1.1 {hostname}.{domainname} {hostname}\n'
|
||||
else:
|
||||
insert_line = f'127.0.1.1 {hostname}\n'
|
||||
|
||||
lines = hosts_path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
new_lines = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if '127.0.1.1' in line:
|
||||
new_lines.append(insert_line)
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not found:
|
||||
new_lines.append(insert_line)
|
||||
|
||||
hosts_path.write_text(''.join(new_lines), encoding='utf-8')
|
||||
|
||||
|
||||
def load_augeas():
|
||||
"""Initialize Augeas."""
|
||||
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
|
||||
|
||||
@ -12,65 +12,6 @@ from plinth import __main__ as plinth_main
|
||||
from plinth.modules.apache import uws_directory_of_user, uws_url_of_user
|
||||
from plinth.modules.config import (_home_page_scid2url, change_home_page,
|
||||
get_home_page, home_page_url2scid)
|
||||
from plinth.modules.config.forms import ConfigurationForm
|
||||
|
||||
|
||||
def test_hostname_field():
|
||||
"""Test that hostname field accepts only valid hostnames."""
|
||||
valid_hostnames = [
|
||||
'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
|
||||
'012345678901234567890123456789012345678901234567890123456789012'
|
||||
]
|
||||
invalid_hostnames = [
|
||||
'', '-', '-a', 'a-', '.a', 'a.', 'a.a', '?', 'a?a',
|
||||
'0123456789012345678901234567890123456789012345678901234567890123'
|
||||
]
|
||||
|
||||
for hostname in valid_hostnames:
|
||||
form = ConfigurationForm({
|
||||
'hostname': hostname,
|
||||
'domainname': 'example.com',
|
||||
'logging_mode': 'volatile'
|
||||
})
|
||||
assert form.is_valid()
|
||||
|
||||
for hostname in invalid_hostnames:
|
||||
form = ConfigurationForm({
|
||||
'hostname': hostname,
|
||||
'domainname': 'example.com',
|
||||
'logging_mode': 'volatile'
|
||||
})
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
def test_domainname_field():
|
||||
"""Test that domainname field accepts only valid domainnames."""
|
||||
valid_domainnames = [
|
||||
'', 'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
|
||||
'example.org', 'a.b.c.d', 'a-0.b-0.c-0',
|
||||
'012345678901234567890123456789012345678901234567890123456789012',
|
||||
((('x' * 63) + '.') * 3) + 'x' * 61
|
||||
]
|
||||
invalid_domainnames = [
|
||||
'-', '-a', 'a-', '.a', 'a.', '?', 'a?a', 'a..a', 'a.-a', '.',
|
||||
((('x' * 63) + '.') * 3) + 'x' * 62, 'x' * 64
|
||||
]
|
||||
|
||||
for domainname in valid_domainnames:
|
||||
form = ConfigurationForm({
|
||||
'hostname': 'example',
|
||||
'domainname': domainname,
|
||||
'logging_mode': 'volatile'
|
||||
})
|
||||
assert form.is_valid()
|
||||
|
||||
for domainname in invalid_domainnames:
|
||||
form = ConfigurationForm({
|
||||
'hostname': 'example',
|
||||
'domainname': domainname,
|
||||
'logging_mode': 'volatile'
|
||||
})
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
def test_homepage_mapping():
|
||||
|
||||
@ -7,10 +7,7 @@ import pytest
|
||||
|
||||
from plinth.tests import functional
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.system, pytest.mark.essential, pytest.mark.domain,
|
||||
pytest.mark.config
|
||||
]
|
||||
pytestmark = [pytest.mark.system, pytest.mark.essential, pytest.mark.config]
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', autouse=True)
|
||||
@ -19,22 +16,6 @@ def fixture_background(session_browser):
|
||||
functional.login(session_browser)
|
||||
|
||||
|
||||
def test_change_hostname(session_browser):
|
||||
"""Test changing the hostname."""
|
||||
functional.set_hostname(session_browser, 'mybox')
|
||||
assert _get_hostname(session_browser) == 'mybox'
|
||||
|
||||
|
||||
def test_change_domain_name(session_browser):
|
||||
"""Test changing the domain name."""
|
||||
functional.set_domain_name(session_browser, 'mydomain.example')
|
||||
assert _get_domain_name(session_browser) == 'mydomain.example'
|
||||
|
||||
# Capitalization is ignored.
|
||||
functional.set_domain_name(session_browser, 'Mydomain.example')
|
||||
assert _get_domain_name(session_browser) == 'mydomain.example'
|
||||
|
||||
|
||||
def test_change_home_page(session_browser):
|
||||
"""Test changing webserver home page."""
|
||||
functional.install(session_browser, 'syncthing')
|
||||
@ -45,16 +26,6 @@ def test_change_home_page(session_browser):
|
||||
assert _check_home_page_redirect(session_browser, 'plinth')
|
||||
|
||||
|
||||
def _get_hostname(browser):
|
||||
functional.nav_to_module(browser, 'config')
|
||||
return browser.find_by_id('id_hostname').value
|
||||
|
||||
|
||||
def _get_domain_name(browser):
|
||||
functional.nav_to_module(browser, 'config')
|
||||
return browser.find_by_id('id_domainname').value
|
||||
|
||||
|
||||
def _set_home_page(browser, home_page):
|
||||
if 'plinth' not in home_page and 'apache' not in home_page:
|
||||
home_page = 'shortcut-' + home_page
|
||||
|
||||
@ -1,21 +1,15 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""FreedomBox views for basic system configuration."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from plinth import views
|
||||
from plinth.modules import config
|
||||
from plinth.signals import (domain_added, domain_removed, post_hostname_change,
|
||||
pre_hostname_change)
|
||||
|
||||
from . import privileged
|
||||
from .forms import ConfigurationForm
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigAppView(views.AppView):
|
||||
"""Serve configuration page."""
|
||||
@ -26,8 +20,6 @@ class ConfigAppView(views.AppView):
|
||||
def get_initial(self):
|
||||
"""Return the current status."""
|
||||
return {
|
||||
'hostname': config.get_hostname(),
|
||||
'domainname': config.get_domainname(),
|
||||
'homepage': config.get_home_page(),
|
||||
'advanced_mode': config.get_advanced_mode(),
|
||||
'logging_mode': privileged.get_logging_mode(),
|
||||
@ -40,29 +32,6 @@ class ConfigAppView(views.AppView):
|
||||
|
||||
is_changed = False
|
||||
|
||||
if old_status['hostname'] != new_status['hostname']:
|
||||
try:
|
||||
set_hostname(new_status['hostname'])
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('Error setting hostname: {exception}').format(
|
||||
exception=exception))
|
||||
else:
|
||||
messages.success(self.request, _('Hostname set'))
|
||||
|
||||
if old_status['domainname'] != new_status['domainname']:
|
||||
try:
|
||||
set_domainname(new_status['domainname'],
|
||||
old_status['domainname'])
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('Error setting domain name: {exception}').format(
|
||||
exception=exception))
|
||||
else:
|
||||
messages.success(self.request, _('Domain name set'))
|
||||
|
||||
if old_status['homepage'] != new_status['homepage']:
|
||||
try:
|
||||
config.change_home_page(new_status['homepage'])
|
||||
@ -98,49 +67,3 @@ class ConfigAppView(views.AppView):
|
||||
messages.success(self.request, _('Configuration updated'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def set_hostname(hostname):
|
||||
"""Set machine hostname and send signals before and after."""
|
||||
old_hostname = config.get_hostname()
|
||||
domainname = config.get_domainname()
|
||||
|
||||
# Hostname should be ASCII. If it's unicode but passed our
|
||||
# valid_hostname check, convert
|
||||
hostname = str(hostname)
|
||||
|
||||
pre_hostname_change.send_robust(sender='config', old_hostname=old_hostname,
|
||||
new_hostname=hostname)
|
||||
|
||||
LOGGER.info('Changing hostname to - %s', hostname)
|
||||
privileged.set_hostname(hostname)
|
||||
|
||||
LOGGER.info('Setting domain name after hostname change - %s', domainname)
|
||||
privileged.set_domainname(domainname)
|
||||
|
||||
post_hostname_change.send_robust(sender='config',
|
||||
old_hostname=old_hostname,
|
||||
new_hostname=hostname)
|
||||
|
||||
|
||||
def set_domainname(domainname, old_domainname):
|
||||
"""Set machine domain name to domainname."""
|
||||
old_domainname = config.get_domainname()
|
||||
|
||||
# Domain name is not case sensitive, but Let's Encrypt certificate
|
||||
# paths use lower-case domain name.
|
||||
domainname = domainname.lower()
|
||||
|
||||
LOGGER.info('Changing domain name to - %s', domainname)
|
||||
privileged.set_domainname(domainname)
|
||||
|
||||
# Update domain registered with Name Services module.
|
||||
if old_domainname:
|
||||
domain_removed.send_robust(sender='config',
|
||||
domain_type='domain-type-static',
|
||||
name=old_domainname)
|
||||
|
||||
if domainname:
|
||||
domain_added.send_robust(sender='config',
|
||||
domain_type='domain-type-static',
|
||||
name=domainname, services='__all__')
|
||||
|
||||
@ -11,7 +11,7 @@ from plinth import app as app_module
|
||||
from plinth import cfg, frontpage, menu
|
||||
from plinth.config import DropinConfigs
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.modules import config
|
||||
from plinth.modules import names
|
||||
from plinth.modules.apache.components import Webserver
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.coturn.components import TurnConfiguration, TurnConsumer
|
||||
@ -136,15 +136,15 @@ class EjabberdApp(app_module.App):
|
||||
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
domainname = config.get_domainname()
|
||||
logger.info('ejabberd service domainname - %s', domainname)
|
||||
domain_name = names.get_domain_name()
|
||||
logger.info('ejabberd service domain name - %s', domain_name)
|
||||
|
||||
privileged.pre_install(domainname)
|
||||
privileged.pre_install(domain_name)
|
||||
# XXX: Configure all other domain names
|
||||
super().setup(old_version)
|
||||
self.get_component('letsencrypt-ejabberd').setup_certificates(
|
||||
[domainname])
|
||||
privileged.setup(domainname)
|
||||
[domain_name])
|
||||
privileged.setup(domain_name)
|
||||
if not old_version:
|
||||
self.enable()
|
||||
|
||||
|
||||
@ -34,18 +34,18 @@ TURN_URI_REGEX = r'(stun|turn):(.*):([0-9]{4})(?:\?transport=(tcp|udp))?'
|
||||
|
||||
|
||||
@privileged
|
||||
def pre_install(domainname: str):
|
||||
def pre_install(domain_name: str):
|
||||
"""Preseed debconf values before packages are installed."""
|
||||
if not domainname:
|
||||
# If new domainname is blank, use hostname instead.
|
||||
domainname = socket.gethostname()
|
||||
if not domain_name:
|
||||
# If new domain_name is blank, use hostname instead.
|
||||
domain_name = socket.gethostname()
|
||||
|
||||
action_utils.debconf_set_selections(
|
||||
['ejabberd ejabberd/hostname string ' + domainname])
|
||||
['ejabberd ejabberd/hostname string ' + domain_name])
|
||||
|
||||
|
||||
@privileged
|
||||
def setup(domainname: str):
|
||||
def setup(domain_name: str):
|
||||
"""Enable LDAP authentication."""
|
||||
with open(EJABBERD_CONFIG, 'r', encoding='utf-8') as file_handle:
|
||||
conf = yaml.load(file_handle)
|
||||
@ -86,7 +86,7 @@ def setup(domainname: str):
|
||||
with open(EJABBERD_CONFIG, 'w', encoding='utf-8') as file_handle:
|
||||
yaml.dump(conf, file_handle)
|
||||
|
||||
_upgrade_config(domainname)
|
||||
_upgrade_config(domain_name)
|
||||
|
||||
try:
|
||||
subprocess.check_output(['ejabberdctl', 'restart'])
|
||||
@ -195,8 +195,8 @@ def get_domains() -> list[str]:
|
||||
|
||||
|
||||
@privileged
|
||||
def add_domain(domainname: str):
|
||||
"""Update ejabberd with new domainname.
|
||||
def add_domain(domain_name: str):
|
||||
"""Update ejabberd with new domain name.
|
||||
|
||||
Restarting ejabberd is handled by letsencrypt-ejabberd component.
|
||||
"""
|
||||
@ -204,11 +204,11 @@ def add_domain(domainname: str):
|
||||
logger.info('ejabberdctl not found')
|
||||
return
|
||||
|
||||
# Add updated domainname to ejabberd hosts list.
|
||||
# Add updated domain name to ejabberd hosts list.
|
||||
with open(EJABBERD_CONFIG, 'r', encoding='utf-8') as file_handle:
|
||||
conf = yaml.load(file_handle)
|
||||
|
||||
conf['hosts'].append(scalarstring.DoubleQuotedScalarString(domainname))
|
||||
conf['hosts'].append(scalarstring.DoubleQuotedScalarString(domain_name))
|
||||
|
||||
conf['hosts'] = list(set(conf['hosts']))
|
||||
|
||||
|
||||
@ -13,17 +13,19 @@
|
||||
<h3>{% trans "Status" %}</h3>
|
||||
|
||||
<p>
|
||||
{% url 'config:index' as index_url %}
|
||||
{% if domainname %}
|
||||
{% blocktrans trimmed with domainname=domainname %}
|
||||
Your XMPP server domain is set to <b>{{ domainname }}</b>. User
|
||||
IDs will look like <i>username@{{ domainname }}</i>. You
|
||||
{% url 'names:index' as names_url %}
|
||||
{% if domain_name %}
|
||||
{% blocktrans trimmed with domain_name=domain_name %}
|
||||
Your XMPP server domain is set to <b>{{ domain_name }}</b>. User
|
||||
IDs will look like <i>username@{{ domain_name }}</i>. You
|
||||
can setup your domain on the system
|
||||
<a href="{{ index_url }}">Configure</a> page.
|
||||
<a href="{{ names_url }}">Name Services</a> page.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
Your XMPP server domain is not set. You can setup your domain on
|
||||
the system <a href="{{ index_url }}">Configure</a> page.
|
||||
{% blocktrans trimmed %}
|
||||
Your XMPP server domain is not set. You can setup your domain on the
|
||||
system <a href="{{ names_url }}">Name Services</a> page.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ class EjabberdAppView(AppView):
|
||||
"""Add service to the context data."""
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
domains = ejabberd.get_domains()
|
||||
context['domainname'] = domains[0] if domains else None
|
||||
context['domain_name'] = domains[0] if domains else None
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -12,7 +12,6 @@ from plinth.config import DropinConfigs
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.modules.apache.components import Webserver
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.modules.config import get_domainname
|
||||
from plinth.modules.firewall.components import (Firewall,
|
||||
FirewallLocalProtection)
|
||||
from plinth.modules.letsencrypt.components import LetsEncrypt
|
||||
@ -223,12 +222,6 @@ class EmailApp(plinth.app.App):
|
||||
self.enable()
|
||||
|
||||
|
||||
def get_domains():
|
||||
"""Return the list of domains configured."""
|
||||
default_domain = get_domainname()
|
||||
return [default_domain] if default_domain else []
|
||||
|
||||
|
||||
def _get_first_admin():
|
||||
"""Return an admin user in the system or None if non exist."""
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
@ -12,7 +12,7 @@ import re
|
||||
|
||||
from plinth.actions import privileged
|
||||
from plinth.app import App
|
||||
from plinth.modules import config
|
||||
from plinth.modules import names
|
||||
from plinth.modules.email import postfix
|
||||
from plinth.modules.names.components import DomainName
|
||||
|
||||
@ -34,7 +34,7 @@ def set_all_domains(primary_domain=None):
|
||||
if not primary_domain:
|
||||
primary_domain = get_domains()['primary_domain']
|
||||
if primary_domain not in all_domains:
|
||||
primary_domain = config.get_domainname() or list(all_domains)[0]
|
||||
primary_domain = names.get_domain_name() or list(all_domains)[0]
|
||||
|
||||
# Update configuration and don't restart daemons
|
||||
set_domains(primary_domain, list(all_domains))
|
||||
|
||||
@ -81,7 +81,7 @@ class FirewallApp(app_module.App):
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
super().setup(old_version)
|
||||
_run_setup()
|
||||
privileged.setup()
|
||||
|
||||
def force_upgrade(self, packages):
|
||||
"""Force upgrade firewalld to resolve conffile prompts."""
|
||||
@ -94,7 +94,7 @@ class FirewallApp(app_module.App):
|
||||
return False
|
||||
|
||||
install(['firewalld'], force_configuration='new')
|
||||
_run_setup()
|
||||
privileged.setup()
|
||||
return True
|
||||
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
@ -107,17 +107,6 @@ class FirewallApp(app_module.App):
|
||||
return results
|
||||
|
||||
|
||||
def _run_setup():
|
||||
"""Run firewalld setup."""
|
||||
privileged.setup()
|
||||
add_service('http', 'external')
|
||||
add_service('http', 'internal')
|
||||
add_service('https', 'external')
|
||||
add_service('https', 'internal')
|
||||
add_service('dns', 'internal')
|
||||
add_service('dhcp', 'internal')
|
||||
|
||||
|
||||
def _get_dbus_proxy(object, interface):
|
||||
"""Return a DBusProxy for a given firewalld object and interface."""
|
||||
connection = gio.bus_get_sync(gio.BusType.SYSTEM)
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
<script src="{% static 'jsxc/jsxc-plinth.js' %}"></script>
|
||||
</head>
|
||||
|
||||
<body data-domain="{{ domainname }}"
|
||||
<body data-domain="{{ domain_name }}"
|
||||
data-jsxc-root="{% static 'jsxc/libjs-jsxc' %}">
|
||||
<div class="container" id="content" role="main">
|
||||
<div class="row">
|
||||
|
||||
@ -5,7 +5,7 @@ from django.http import Http404
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
import plinth.app as app_module
|
||||
from plinth.modules import config
|
||||
from plinth.modules import names
|
||||
|
||||
|
||||
class JsxcView(TemplateView):
|
||||
@ -24,5 +24,5 @@ class JsxcView(TemplateView):
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
"""Add domain information to view context."""
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['domainname'] = config.get_domainname()
|
||||
context['domain_name'] = names.get_domain_name()
|
||||
return context
|
||||
|
||||
@ -104,18 +104,16 @@ class LetsEncryptApp(app_module.App):
|
||||
return results
|
||||
|
||||
def repair(self, failed_checks: list) -> bool:
|
||||
"""Try to repair failed diagnostics.
|
||||
|
||||
Returns whether the app setup should be re-run.
|
||||
"""
|
||||
"""Handle repair for custom diagnostic."""
|
||||
status = get_status()
|
||||
|
||||
# Obtain/re-obtain certificates for failing domains
|
||||
for failed_check in failed_checks:
|
||||
if not failed_check.check_id.startswith('letsencrypt-domain'):
|
||||
remaining_checks = []
|
||||
for check in failed_checks:
|
||||
if not check.check_id.startswith('letsencrypt-domain'):
|
||||
remaining_checks.append(check)
|
||||
continue
|
||||
|
||||
domain = failed_check.parameters['domain']
|
||||
# Obtain/re-obtain certificates for failing domains
|
||||
domain = check.parameters['domain']
|
||||
try:
|
||||
domain_status = status['domains'][domain]
|
||||
if domain_status.get('certificate_available', False):
|
||||
@ -128,7 +126,7 @@ class LetsEncryptApp(app_module.App):
|
||||
# Add the error message to thread local storage
|
||||
store_error_message(str(error))
|
||||
|
||||
return False
|
||||
return super().repair(remaining_checks)
|
||||
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
|
||||
@ -44,7 +44,7 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
def __init__(self, component_id, domains=None, daemons=None,
|
||||
should_copy_certificates=False, private_key_path=None,
|
||||
certificate_path=None, user_owner=None, group_owner=None,
|
||||
managing_app=None):
|
||||
managing_app=None, reload_daemons=False):
|
||||
"""Initialize the Let's Encrypt component.
|
||||
|
||||
component_id should be a unique ID across all components of an app and
|
||||
@ -107,6 +107,8 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
objects to which the app is allowed to write certificates and other
|
||||
files to.
|
||||
|
||||
reload_daemons is boolean to indicate if the daemons should be reloaded
|
||||
instead of restarted (which is default).
|
||||
"""
|
||||
if should_copy_certificates:
|
||||
if (not private_key_path or not certificate_path or not user_owner
|
||||
@ -116,6 +118,7 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
super().__init__(component_id)
|
||||
self._domains = domains
|
||||
self.daemons = daemons
|
||||
self.reload_daemons = reload_daemons
|
||||
self.should_copy_certificates = should_copy_certificates
|
||||
self.private_key_path = private_key_path
|
||||
self.certificate_path = certificate_path
|
||||
@ -170,7 +173,10 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
self._copy_self_signed_certificates([domain])
|
||||
|
||||
for daemon in self.daemons:
|
||||
service_privileged.try_restart(daemon)
|
||||
if self.reload_daemons:
|
||||
service_privileged.try_reload_or_restart(daemon)
|
||||
else:
|
||||
service_privileged.try_restart(daemon)
|
||||
|
||||
def get_status(self):
|
||||
"""Return the status of certificates for all interested domains.
|
||||
@ -215,7 +221,10 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
self._copy_letsencrypt_certificates(interested_domains, lineage)
|
||||
|
||||
for daemon in self.daemons:
|
||||
service_privileged.try_restart(daemon)
|
||||
if self.reload_daemons:
|
||||
service_privileged.try_reload_or_restart(daemon)
|
||||
else:
|
||||
service_privileged.try_restart(daemon)
|
||||
|
||||
def on_certificate_renewed(self, domains, lineage):
|
||||
"""Handle event when a certificate is renewed.
|
||||
@ -249,7 +258,10 @@ class LetsEncrypt(app.FollowerComponent):
|
||||
self._copy_self_signed_certificates(interested_domains)
|
||||
|
||||
for daemon in self.daemons:
|
||||
service_privileged.try_restart(daemon)
|
||||
if self.reload_daemons:
|
||||
service_privileged.try_reload_or_restart(daemon)
|
||||
else:
|
||||
service_privileged.try_restart(daemon)
|
||||
|
||||
def on_certificate_deleted(self, domains, lineage):
|
||||
"""Handle event when a certificate is deleted.
|
||||
|
||||
@ -108,9 +108,9 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% url 'config:index' as config_url %}
|
||||
{% url 'names:index' as names_url %}
|
||||
{% blocktrans trimmed %}
|
||||
No domains have been configured. <a href="{{ config_url }}">Configure
|
||||
No domains have been configured. <a href="{{ names_url }}">Configure
|
||||
domains</a> to be able to obtain certificates for them.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
Test the Let's Encrypt component for managing certificates.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import random
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
@ -20,20 +22,16 @@ def fixture_empty_letsencrypt_list():
|
||||
@pytest.fixture(name='component')
|
||||
def fixture_component():
|
||||
"""Create a new component for testing."""
|
||||
return LetsEncrypt(
|
||||
reload_daemons = random.choice([True, False])
|
||||
component = LetsEncrypt(
|
||||
'test-component', domains=['valid.example', 'invalid.example'],
|
||||
daemons=['test-daemon'], should_copy_certificates=True,
|
||||
private_key_path='/etc/test-app/{domain}/private.path',
|
||||
certificate_path='/etc/test-app/{domain}/certificate.path',
|
||||
user_owner='test-user', group_owner='test-group',
|
||||
managing_app='test-app')
|
||||
|
||||
|
||||
@pytest.fixture(name='try_restart')
|
||||
def fixture_try_restart():
|
||||
"""Patch and return service.try_restart privileged call."""
|
||||
with patch('plinth.privileged.service.try_restart') as try_restart:
|
||||
yield try_restart
|
||||
managing_app='test-app', reload_daemons=reload_daemons)
|
||||
assert component.reload_daemons == reload_daemons
|
||||
return component
|
||||
|
||||
|
||||
@pytest.fixture(name='copy_certificate')
|
||||
@ -100,6 +98,7 @@ def test_init_without_arguments():
|
||||
assert component.user_owner is None
|
||||
assert component.group_owner is None
|
||||
assert component.managing_app is None
|
||||
assert not component.reload_daemons
|
||||
assert len(component._all) == 1
|
||||
assert component._all['test-component'] == component
|
||||
|
||||
@ -173,58 +172,74 @@ def _assert_copy_certificate_called(component, copy_certificate, domains):
|
||||
copy_certificate.assert_has_calls(expected_calls, any_order=True)
|
||||
|
||||
|
||||
def _assert_restarted_daemons(daemons, try_restart):
|
||||
@contextlib.contextmanager
|
||||
def _assert_restarted_daemons(component, daemons=None):
|
||||
"""Check that a call has restarted the daemons of a component."""
|
||||
daemons = daemons if daemons is not None else component.daemons
|
||||
|
||||
expected_calls = [call(daemon) for daemon in daemons]
|
||||
try_restart.assert_has_calls(expected_calls, any_order=True)
|
||||
with patch('plinth.privileged.service.try_reload_or_restart'
|
||||
) as try_reload_or_restart, patch(
|
||||
'plinth.privileged.service.try_restart') as try_restart:
|
||||
yield
|
||||
|
||||
if component.reload_daemons:
|
||||
try_reload_or_restart.assert_has_calls(expected_calls,
|
||||
any_order=True)
|
||||
try_restart.assert_not_called()
|
||||
else:
|
||||
try_restart.assert_has_calls(expected_calls, any_order=True)
|
||||
try_reload_or_restart.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_certificates(copy_certificate, try_restart, get_status,
|
||||
component):
|
||||
def test_setup_certificates(copy_certificate, get_status, component):
|
||||
"""Test that initial copying of certs for an app works."""
|
||||
component.setup_certificates()
|
||||
with _assert_restarted_daemons(component):
|
||||
component.setup_certificates()
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
'invalid.example': 'invalid'
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_setup_certificates_without_copy(copy_certificate, try_restart,
|
||||
get_status, component):
|
||||
def test_setup_certificates_without_copy(copy_certificate, get_status,
|
||||
component):
|
||||
"""Test that initial copying of certs for an app works."""
|
||||
component.should_copy_certificates = False
|
||||
component.setup_certificates()
|
||||
with _assert_restarted_daemons(component):
|
||||
component.setup_certificates()
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_setup_certificates_with_app_domains(copy_certificate, try_restart,
|
||||
get_status, component):
|
||||
def test_setup_certificates_with_app_domains(copy_certificate, get_status,
|
||||
component):
|
||||
"""Test that initial copying of certs for an app works."""
|
||||
component._domains = ['irrelevant1.example', 'irrelevant2.example']
|
||||
component.setup_certificates(
|
||||
app_domains=['valid.example', 'invalid.example'])
|
||||
with _assert_restarted_daemons(component):
|
||||
component.setup_certificates(
|
||||
app_domains=['valid.example', 'invalid.example'])
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
'invalid.example': 'invalid'
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_setup_certificates_with_all_domains(domain_list, copy_certificate,
|
||||
try_restart, get_status,
|
||||
component):
|
||||
get_status, component):
|
||||
"""Test that initial copying for certs works when app domains is '*'."""
|
||||
component._domains = '*'
|
||||
component.setup_certificates()
|
||||
with _assert_restarted_daemons(component):
|
||||
component.setup_certificates()
|
||||
|
||||
_assert_copy_certificate_called(
|
||||
component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
'invalid1.example': 'invalid',
|
||||
'invalid2.example': 'invalid'
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def _assert_compare_certificate_called(component, compare_certificate,
|
||||
@ -278,139 +293,148 @@ def test_get_status_without_copy(component, get_status):
|
||||
}
|
||||
|
||||
|
||||
def test_on_certificate_obtained(copy_certificate, try_restart, component):
|
||||
def test_on_certificate_obtained(copy_certificate, component):
|
||||
"""Test that certificate obtained event handler works."""
|
||||
component.on_certificate_obtained(['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_obtained(
|
||||
['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_obtained_with_all_domains(copy_certificate,
|
||||
try_restart, component):
|
||||
def test_on_certificate_obtained_with_all_domains(copy_certificate, component):
|
||||
"""Test that certificate obtained event handler works for app with
|
||||
all domains.
|
||||
"""
|
||||
component._domains = '*'
|
||||
component.on_certificate_obtained(['valid.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_obtained(
|
||||
['valid.example'], '/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_obtained_irrelevant(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_obtained_irrelevant(copy_certificate, component):
|
||||
"""Test that certificate obtained event handler works with
|
||||
irrelevant domain.
|
||||
"""
|
||||
component.on_certificate_obtained(
|
||||
['irrelevant.example'], '/etc/letsencrypt/live/irrelevant.example/')
|
||||
with _assert_restarted_daemons(component, []):
|
||||
component.on_certificate_obtained(
|
||||
['irrelevant.example'],
|
||||
'/etc/letsencrypt/live/irrelevant.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons([], try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_obtained_without_copy(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_obtained_without_copy(copy_certificate, component):
|
||||
"""Test that certificate obtained event handler works without copying."""
|
||||
component.should_copy_certificates = False
|
||||
component.on_certificate_obtained(['valid.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_obtained(
|
||||
['valid.example'], '/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_renewed(copy_certificate, try_restart, component):
|
||||
def test_on_certificate_renewed(copy_certificate, component):
|
||||
"""Test that certificate renewed event handler works."""
|
||||
component.on_certificate_renewed(['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_renewed(
|
||||
['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'valid',
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_renewed_irrelevant(copy_certificate, try_restart,
|
||||
component):
|
||||
"""Test that certificate renewed event handler works for
|
||||
irrelevant domains.
|
||||
"""
|
||||
component.on_certificate_renewed(
|
||||
['irrelevant.example'], '/etc/letsencrypt/live/irrelevant.example/')
|
||||
def test_on_certificate_renewed_irrelevant(copy_certificate, component):
|
||||
"""Test that cert renewed event handler works for irrelevant domains."""
|
||||
with _assert_restarted_daemons(component, []):
|
||||
component.on_certificate_renewed(
|
||||
['irrelevant.example'],
|
||||
'/etc/letsencrypt/live/irrelevant.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons([], try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_renewed_without_copy(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_renewed_without_copy(copy_certificate, component):
|
||||
"""Test that certificate renewed event handler works without copying."""
|
||||
component.should_copy_certificates = False
|
||||
component.on_certificate_renewed(['valid.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_renewed(
|
||||
['valid.example'], '/etc/letsencrypt/live/valid.example/')
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_revoked(copy_certificate, try_restart, component):
|
||||
def test_on_certificate_revoked(copy_certificate, component):
|
||||
"""Test that certificate revoked event handler works."""
|
||||
component.on_certificate_revoked(['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_revoked(
|
||||
['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'invalid',
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_revoked_irrelevant(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_revoked_irrelevant(copy_certificate, component):
|
||||
"""Test that certificate revoked event handler works for
|
||||
irrelevant domains.
|
||||
"""
|
||||
component.on_certificate_revoked(
|
||||
['irrelevant.example'], '/etc/letsencrypt/live/irrelevant.example/')
|
||||
with _assert_restarted_daemons(component, []):
|
||||
component.on_certificate_revoked(
|
||||
['irrelevant.example'],
|
||||
'/etc/letsencrypt/live/irrelevant.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons([], try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_revoked_without_copy(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_revoked_without_copy(copy_certificate, component):
|
||||
"""Test that certificate revoked event handler works without copying."""
|
||||
component.should_copy_certificates = False
|
||||
component.on_certificate_revoked(['valid.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_revoked(
|
||||
['valid.example'], '/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_deleted(copy_certificate, try_restart, component):
|
||||
def test_on_certificate_deleted(copy_certificate, component):
|
||||
"""Test that certificate deleted event handler works."""
|
||||
component.on_certificate_deleted(['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_deleted(
|
||||
['valid.example', 'irrelevant.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {
|
||||
'valid.example': 'invalid',
|
||||
})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_deleted_irrelevant(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_deleted_irrelevant(copy_certificate, component):
|
||||
"""Test that certificate deleted event handler works for
|
||||
irrelevant domains.
|
||||
"""
|
||||
component.on_certificate_deleted(
|
||||
['irrelevant.example'], '/etc/letsencrypt/live/irrelevant.example/')
|
||||
with _assert_restarted_daemons(component, []):
|
||||
component.on_certificate_deleted(
|
||||
['irrelevant.example'],
|
||||
'/etc/letsencrypt/live/irrelevant.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons([], try_restart)
|
||||
|
||||
|
||||
def test_on_certificate_deleted_without_copy(copy_certificate, try_restart,
|
||||
component):
|
||||
def test_on_certificate_deleted_without_copy(copy_certificate, component):
|
||||
"""Test that certificate deleted event handler works without copying."""
|
||||
component.should_copy_certificates = False
|
||||
component.on_certificate_deleted(['valid.example'],
|
||||
'/etc/letsencrypt/live/valid.example/')
|
||||
with _assert_restarted_daemons(component):
|
||||
component.on_certificate_deleted(
|
||||
['valid.example'], '/etc/letsencrypt/live/valid.example/')
|
||||
|
||||
_assert_copy_certificate_called(component, copy_certificate, {})
|
||||
_assert_restarted_daemons(component.daemons, try_restart)
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
</div>
|
||||
|
||||
{% if not domain_names %}
|
||||
{% url 'config:index' as config_url %}
|
||||
{% url 'names:index' as config_url %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No domain(s) are available. <a href="{{ config_url }}">Configure</a>
|
||||
|
||||
@ -40,7 +40,7 @@ class MediaWikiApp(app_module.App):
|
||||
|
||||
app_id = 'mediawiki'
|
||||
|
||||
_version = 12
|
||||
_version = 13
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create components for the app."""
|
||||
@ -86,7 +86,7 @@ class MediaWikiApp(app_module.App):
|
||||
self.add(webserver)
|
||||
|
||||
webserver = Webserver('webserver-mediawiki-freedombox',
|
||||
'mediawiki-freedombox')
|
||||
'mediawiki-freedombox', last_updated_version=13)
|
||||
self.add(webserver)
|
||||
|
||||
daemon = Daemon('daemon-mediawiki', 'mediawiki-jobrunner')
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ index.php [L]
|
||||
</IfModule>
|
||||
<IfModule proxy_fcgi_module>
|
||||
ProxyFCGISetEnvIf true PHP_VALUE "max_execution_time = 100"
|
||||
</IfModule>
|
||||
</Directory>
|
||||
|
||||
<Directory /var/lib/mediawiki/images>
|
||||
|
||||
@ -33,7 +33,7 @@ class MinifluxApp(app_module.App):
|
||||
|
||||
app_id = 'miniflux'
|
||||
|
||||
_version = 1
|
||||
_version = 2
|
||||
|
||||
def __init__(self):
|
||||
"""Create components for the app."""
|
||||
@ -88,6 +88,7 @@ class MinifluxApp(app_module.App):
|
||||
"""Install and configure the app."""
|
||||
privileged.pre_setup()
|
||||
super().setup(old_version)
|
||||
privileged.setup(old_version)
|
||||
if not old_version:
|
||||
self.enable()
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -40,7 +41,7 @@ def _env_file_to_dict(env_vars: str) -> dict[str, str]:
|
||||
|
||||
@privileged
|
||||
def pre_setup():
|
||||
"""Perform post-install actions for Miniflux."""
|
||||
"""Perform pre-install actions for Miniflux."""
|
||||
vars_file = pathlib.Path(ENV_VARS_FILE)
|
||||
vars_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@ -53,6 +54,20 @@ def pre_setup():
|
||||
vars_file.write_text(_dict_to_env_file(new_settings))
|
||||
|
||||
|
||||
@privileged
|
||||
def setup(old_version: int):
|
||||
"""Perform post-install actions for Miniflux."""
|
||||
# Fix incorrect permissions on database file in version 2.2.0-2. See
|
||||
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1081562 . Can be
|
||||
# removed after the fix for the bug reaches Trixie/testing.
|
||||
shutil.chown(DATABASE_FILE, user='miniflux', group='root')
|
||||
if not old_version or action_utils.service_is_enabled('miniflux'):
|
||||
# If the service was tried too many times already, it won't
|
||||
# successfully restart.
|
||||
action_utils.service_reset_failed('miniflux')
|
||||
action_utils.service_restart('miniflux')
|
||||
|
||||
|
||||
def _run_miniflux_interactively(command: str, username: str,
|
||||
password: str) -> Tuple[str, dict]:
|
||||
"""Fill interactive terminal prompt for username and password."""
|
||||
|
||||
@ -4,16 +4,26 @@ FreedomBox app to configure name services.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from plinth import app as app_module
|
||||
from plinth import cfg, menu
|
||||
from plinth import cfg, menu, network
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
DiagnosticCheckParameters, Result)
|
||||
from plinth.modules.backups.components import BackupRestore
|
||||
from plinth.signals import domain_added, domain_removed
|
||||
from plinth.modules.names.components import DomainType
|
||||
from plinth.package import Packages
|
||||
from plinth.privileged import service as service_privileged
|
||||
from plinth.signals import (domain_added, domain_removed, post_hostname_change,
|
||||
pre_hostname_change)
|
||||
from plinth.utils import format_lazy
|
||||
|
||||
from . import components, manifest
|
||||
from . import components, manifest, privileged
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -32,7 +42,7 @@ class NamesApp(app_module.App):
|
||||
|
||||
app_id = 'names'
|
||||
|
||||
_version = 1
|
||||
_version = 2
|
||||
|
||||
can_be_disabled = False
|
||||
|
||||
@ -50,6 +60,18 @@ class NamesApp(app_module.App):
|
||||
parent_url_name='system:visibility', order=10)
|
||||
self.add(menu_item)
|
||||
|
||||
# 'ip' utility is needed from 'iproute2' package.
|
||||
packages = Packages('packages-names',
|
||||
['systemd-resolved', 'libnss-resolve', 'iproute2'])
|
||||
self.add(packages)
|
||||
|
||||
domain_type = DomainType('domain-type-static', _('Domain Name'),
|
||||
'names:domains', can_have_certificate=True)
|
||||
self.add(domain_type)
|
||||
|
||||
daemon = Daemon('daemon-names', 'systemd-resolved')
|
||||
self.add(daemon)
|
||||
|
||||
backup_restore = BackupRestore('backup-restore-names',
|
||||
**manifest.backup)
|
||||
self.add(backup_restore)
|
||||
@ -60,12 +82,59 @@ class NamesApp(app_module.App):
|
||||
domain_added.connect(on_domain_added)
|
||||
domain_removed.connect(on_domain_removed)
|
||||
|
||||
# Register domain with Name Services module.
|
||||
domain_name = get_domain_name()
|
||||
if domain_name:
|
||||
domain_added.send_robust(sender='names',
|
||||
domain_type='domain-type-static',
|
||||
name=domain_name, services='__all__')
|
||||
|
||||
def diagnose(self) -> list[DiagnosticCheck]:
|
||||
"""Run diagnostics and return the results."""
|
||||
results = super().diagnose()
|
||||
results.append(diagnose_resolution('deb.debian.org'))
|
||||
return results
|
||||
|
||||
def setup(self, old_version):
|
||||
"""Install and configure the app."""
|
||||
super().setup(old_version)
|
||||
|
||||
# Fresh install or upgrading to version 2
|
||||
if old_version < 2:
|
||||
privileged.set_resolved_configuration(dns_fallback=True)
|
||||
|
||||
# Load the configuration files for systemd-resolved provided by
|
||||
# FreedomBox.
|
||||
service_privileged.restart('systemd-resolved')
|
||||
|
||||
# After systemd-resolved is freshly installed, /etc/resolve.conf
|
||||
# becomes a symlink to configuration pointing to systemd-resovled stub
|
||||
# resolver. However, the old contents are not fed from network-manager
|
||||
# (if it was present earlier and wrote to /etc/resolve.conf). Ask
|
||||
# network-manager to feed the DNS servers from the connections it has
|
||||
# established to systemd-resolved so that using fallback DNS servers is
|
||||
# not necessary.
|
||||
network.refeed_dns()
|
||||
|
||||
self.enable()
|
||||
|
||||
|
||||
def diagnose_resolution(domain: str) -> DiagnosticCheck:
|
||||
"""Perform a diagnostic check for whether a domain can be resolved."""
|
||||
result = Result.NOT_DONE
|
||||
try:
|
||||
subprocess.run(['resolvectl', 'query', '--cache=no', domain],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=True)
|
||||
result = Result.PASSED
|
||||
except subprocess.CalledProcessError:
|
||||
result = Result.FAILED
|
||||
|
||||
description = gettext_noop('Resolve domain name: {domain}')
|
||||
parameters: DiagnosticCheckParameters = {'domain': domain}
|
||||
return DiagnosticCheck('names-resolve', description, result, parameters)
|
||||
|
||||
|
||||
def on_domain_added(sender, domain_type, name='', description='',
|
||||
services=None, **kwargs):
|
||||
"""Add domain to global list."""
|
||||
@ -103,6 +172,39 @@ def on_domain_removed(sender, domain_type, name='', **kwargs):
|
||||
######################################################
|
||||
|
||||
|
||||
def get_domain_name():
|
||||
"""Return the currently set static domain name."""
|
||||
fqdn = socket.getfqdn()
|
||||
return '.'.join(fqdn.split('.')[1:])
|
||||
|
||||
|
||||
def get_hostname():
|
||||
"""Return the hostname."""
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def set_hostname(hostname):
|
||||
"""Set machine hostname and send signals before and after."""
|
||||
old_hostname = get_hostname()
|
||||
domain_name = get_domain_name()
|
||||
|
||||
# Hostname should be ASCII. If it's unicode but passed our
|
||||
# valid_hostname check, convert
|
||||
hostname = str(hostname)
|
||||
|
||||
pre_hostname_change.send_robust(sender='names', old_hostname=old_hostname,
|
||||
new_hostname=hostname)
|
||||
|
||||
logger.info('Changing hostname to - %s', hostname)
|
||||
privileged.set_hostname(hostname)
|
||||
|
||||
logger.info('Setting domain name after hostname change - %s', domain_name)
|
||||
privileged.set_domain_name(domain_name)
|
||||
|
||||
post_hostname_change.send_robust(sender='names', old_hostname=old_hostname,
|
||||
new_hostname=hostname)
|
||||
|
||||
|
||||
def get_available_tls_domains():
|
||||
"""Return an iterator with all domains able to have a certificate."""
|
||||
return (domain.name for domain in components.DomainName.list()
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
[Resolve]
|
||||
# Use Avahi daemon instead of systemd-resolved for mDNS. Many programs such as
|
||||
# GNOME and cups still depend on avahi-daemon.
|
||||
MulticastDNS=no
|
||||
@ -0,0 +1,12 @@
|
||||
[Resolve]
|
||||
# Debian removes the default fallback DNS servers likely because they could be
|
||||
# considered a privacy violation. However, when systemd-resolved package is
|
||||
# first installed, the post install script recommends a reboot instead of
|
||||
# feeding the currently configured nameservers from /etc/resolve.conf into
|
||||
# systemd-resolved. Immediately, this causes the system not be able to connect
|
||||
# to any external servers. While this may be acceptable solution for interactive
|
||||
# systems and pre-built images, FreedomBox has to a) be available for remote
|
||||
# access b) perform upgrades without user intervention (and without reboot until
|
||||
# a day). To mitigate privacy concerns, an option to disable these fallback
|
||||
# servers will be provided in the UI.
|
||||
FallbackDNS=1.1.1.1#cloudflare-dns.com 8.8.8.8#dns.google 1.0.0.1#cloudflare-dns.com 8.8.4.4#dns.google 2606:4700:4700::1111#cloudflare-dns.com 2001:4860:4860::8888#dns.google 2606:4700:4700::1001#cloudflare-dns.com 2001:4860:4860::8844#dns.google
|
||||
118
plinth/modules/names/forms.py
Normal file
118
plinth/modules/names/forms.py
Normal file
@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Forms for the names app."""
|
||||
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from plinth import cfg
|
||||
from plinth.utils import format_lazy
|
||||
|
||||
HOSTNAME_REGEX = r'^[a-zA-Z0-9]([-a-zA-Z0-9]{,61}[a-zA-Z0-9])?$'
|
||||
|
||||
|
||||
class NamesConfigurationForm(forms.Form):
|
||||
"""Form to configure names app."""
|
||||
|
||||
dns_over_tls = forms.ChoiceField(
|
||||
label=_('Use DNS-over-TLS for resolving domains (global preference)'),
|
||||
widget=forms.RadioSelect, choices=[
|
||||
('yes',
|
||||
format_lazy(
|
||||
'Yes. Encrypt connections to the DNS server. <p '
|
||||
'class="help-block">This improves privacy as domain name '
|
||||
'queries will not be made as plain text over the network. It '
|
||||
'also improves security as responses from the server cannot '
|
||||
'be manipulated. If the configured DNS servers do not '
|
||||
'support DNS-over-TLS, all name resolutions will fail. If '
|
||||
'your DNS provider (likely your ISP) does not support '
|
||||
'DNS-over-TLS or blocks some domains, you can configure '
|
||||
'well-known public DNS servers in individual network '
|
||||
'connection settings.</p>', allow_markup=True)),
|
||||
('opportunistic',
|
||||
format_lazy(
|
||||
'Opportunistic. <p class="help-block">Encrypt connections to '
|
||||
'the DNS server if the server supports DNS-over-TLS. '
|
||||
'Otherwise, use unencrypted connections. There is no '
|
||||
'protection against response manipulation.</p>',
|
||||
allow_markup=True)),
|
||||
('no',
|
||||
format_lazy(
|
||||
'No. <p class="help-block">Do not encrypt domain name '
|
||||
'resolutions.</p>', allow_markup=True)),
|
||||
], initial='no')
|
||||
|
||||
dnssec = forms.ChoiceField(
|
||||
label=_('Use DNSSEC when resolving domains (global preference)'),
|
||||
widget=forms.RadioSelect, choices=[
|
||||
('yes',
|
||||
format_lazy(
|
||||
'Yes. Verify authenticity and integrity of domain '
|
||||
'resolutions. <p class="help-block">This improves security. '
|
||||
'If the configured DNS servers do not support DNSSEC, all '
|
||||
'name resolutions will fail. If your DNS provider (likely '
|
||||
'your ISP) does not support DNSSEC or is manipulating '
|
||||
'responses, you can configure well-known public DNS servers '
|
||||
'in individual network connection settings.</p>',
|
||||
allow_markup=True)),
|
||||
('allow-downgrade',
|
||||
format_lazy(
|
||||
'Allow downgrade. <p class="help-block">Verify name '
|
||||
'resolutions done by the DNS server if the server supports '
|
||||
'DNSSEC. Otherwise, allow unverified resolutions. Limited '
|
||||
'improvement to security. Detecting whether a DNS server '
|
||||
'supports DNSSEC is not very reliable currently.</p>',
|
||||
allow_markup=True)),
|
||||
('no',
|
||||
format_lazy(
|
||||
'No. <p class="help-block">Do not verify domain name '
|
||||
'resolutions.</p>', allow_markup=True)),
|
||||
], initial='no')
|
||||
|
||||
|
||||
class HostnameForm(forms.Form):
|
||||
"""Form to update system's hostname."""
|
||||
# See:
|
||||
# https://tools.ietf.org/html/rfc952
|
||||
# https://tools.ietf.org/html/rfc1035#section-2.3.1
|
||||
# https://tools.ietf.org/html/rfc1123#section-2
|
||||
# https://tools.ietf.org/html/rfc2181#section-11
|
||||
hostname = forms.CharField(
|
||||
label=_('Hostname'), help_text=format_lazy(
|
||||
_('Hostname is the local name by which other devices on the local '
|
||||
'network can reach your {box_name}. It must start and end with '
|
||||
'an alphabet or a digit and have as interior characters only '
|
||||
'alphabets, digits and hyphens. Total length must be 63 '
|
||||
'characters or less.'), box_name=_(cfg.box_name)), validators=[
|
||||
validators.RegexValidator(HOSTNAME_REGEX,
|
||||
_('Invalid hostname'))
|
||||
], strip=True)
|
||||
|
||||
|
||||
def _domain_label_validator(domain_name):
|
||||
"""Validate domain name labels."""
|
||||
for label in domain_name.split('.'):
|
||||
if not re.match(HOSTNAME_REGEX, label):
|
||||
raise ValidationError(_('Invalid domain name'))
|
||||
|
||||
|
||||
class DomainNameForm(forms.Form):
|
||||
"""Form to update system's static domain name."""
|
||||
|
||||
domain_name = forms.CharField(
|
||||
label=_('Domain Name'), help_text=format_lazy(
|
||||
_('Domain name is the global name by which other devices on the '
|
||||
'Internet can reach your {box_name}. It must consist of '
|
||||
'labels separated by dots. Each label must start and end '
|
||||
'with an alphabet or a digit and have as interior characters '
|
||||
'only alphabets, digits and hyphens. Length of each label '
|
||||
'must be 63 characters or less. Total length of domain name '
|
||||
'must be 253 characters or less.'), box_name=_(cfg.box_name)),
|
||||
required=False, validators=[
|
||||
validators.RegexValidator(
|
||||
r'^[a-zA-Z0-9]([-a-zA-Z0-9.]{,251}[a-zA-Z0-9])?$',
|
||||
_('Invalid domain name')), _domain_label_validator
|
||||
], strip=True)
|
||||
120
plinth/modules/names/privileged.py
Normal file
120
plinth/modules/names/privileged.py
Normal file
@ -0,0 +1,120 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Configure Names App."""
|
||||
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
import augeas
|
||||
|
||||
from plinth import action_utils
|
||||
from plinth.actions import privileged
|
||||
|
||||
fallback_conf = pathlib.Path(
|
||||
'/etc/systemd/resolved.conf.d/freedombox-fallback.conf')
|
||||
override_conf = pathlib.Path('/etc/systemd/resolved.conf.d/freedombox.conf')
|
||||
source_fallback_conf = pathlib.Path(
|
||||
'/usr/share/freedombox'
|
||||
'/etc/systemd/resolved.conf.d/freedombox-fallback.conf')
|
||||
|
||||
|
||||
@privileged
|
||||
def set_hostname(hostname: str):
|
||||
"""Set system hostname using hostnamectl."""
|
||||
subprocess.run(
|
||||
['hostnamectl', 'set-hostname', '--transient', '--static', hostname],
|
||||
check=True)
|
||||
action_utils.service_restart('avahi-daemon')
|
||||
|
||||
|
||||
@privileged
|
||||
def set_domain_name(domain_name: str | None = None):
|
||||
"""Set system's static domain name in /etc/hosts."""
|
||||
hostname = subprocess.check_output(['hostname']).decode().strip()
|
||||
hosts_path = pathlib.Path('/etc/hosts')
|
||||
if domain_name:
|
||||
insert_line = f'127.0.1.1 {hostname}.{domain_name} {hostname}\n'
|
||||
else:
|
||||
insert_line = f'127.0.1.1 {hostname}\n'
|
||||
|
||||
lines = hosts_path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
new_lines = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if '127.0.1.1' in line:
|
||||
new_lines.append(insert_line)
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not found:
|
||||
new_lines.append(insert_line)
|
||||
|
||||
hosts_path.write_text(''.join(new_lines), encoding='utf-8')
|
||||
|
||||
|
||||
@privileged
|
||||
def set_resolved_configuration(dns_fallback: bool | None = None,
|
||||
dns_over_tls: str | None = None,
|
||||
dnssec: str | None = None):
|
||||
"""Set systemd-resolved configuration options."""
|
||||
if dns_fallback is not None:
|
||||
_set_enable_dns_fallback(dns_fallback)
|
||||
|
||||
if dns_over_tls is not None or dnssec is not None:
|
||||
_set_resolved_configuration(dns_over_tls, dnssec)
|
||||
|
||||
# Workaround buggy reload that does not apply DNS-over-TLS changes
|
||||
# properly.
|
||||
action_utils.service_try_restart('systemd-resolved')
|
||||
|
||||
|
||||
def get_resolved_configuration() -> dict[str, bool]:
|
||||
"""Return systemd-resolved configuration."""
|
||||
configuration = _get_resolved_configuration()
|
||||
configuration['dns_fallback'] = fallback_conf.exists()
|
||||
return configuration
|
||||
|
||||
|
||||
def _set_enable_dns_fallback(dns_fallback: bool):
|
||||
"""Update whether to use DNS fallback servers."""
|
||||
if dns_fallback:
|
||||
if not fallback_conf.exists():
|
||||
fallback_conf.parent.mkdir(parents=True, exist_ok=True)
|
||||
fallback_conf.symlink_to(source_fallback_conf)
|
||||
else:
|
||||
fallback_conf.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _load_augeas():
|
||||
"""Initialize Augeas."""
|
||||
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
|
||||
augeas.Augeas.NO_MODL_AUTOLOAD)
|
||||
aug.transform('Systemd', str(override_conf))
|
||||
aug.set('/augeas/context', '/files' + str(override_conf))
|
||||
aug.load()
|
||||
return aug
|
||||
|
||||
|
||||
def _get_resolved_configuration():
|
||||
"""Return overridden configuration for systemd-resolved."""
|
||||
aug = _load_augeas()
|
||||
# Default value for DNSSEC upstream is 'allow-downgrade', but in Debian it
|
||||
# is 'no'.
|
||||
return {
|
||||
'dns_over_tls': aug.get('Resolve/DNSOverTLS/value') or 'no',
|
||||
'dnssec': aug.get('Resolve/DNSSEC/value') or 'no'
|
||||
}
|
||||
|
||||
|
||||
def _set_resolved_configuration(dns_over_tls: str | None = None,
|
||||
dnssec: str | None = None):
|
||||
"""Write configuration into a systemd-resolved override file."""
|
||||
aug = _load_augeas()
|
||||
|
||||
if dns_over_tls is not None:
|
||||
aug.set('Resolve/DNSOverTLS/value', dns_over_tls)
|
||||
|
||||
if dnssec is not None:
|
||||
aug.set('Resolve/DNSSEC/value', dnssec)
|
||||
|
||||
aug.save()
|
||||
194
plinth/modules/names/resolved.py
Normal file
194
plinth/modules/names/resolved.py
Normal file
@ -0,0 +1,194 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Module to interact with systemd-resolved over DBus."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from plinth.utils import import_from_gi
|
||||
|
||||
gio = import_from_gi('Gio', '2.0')
|
||||
|
||||
RESOLVE_NAME = 'org.freedesktop.resolve1'
|
||||
RESOLVE_PATH = '/org/freedesktop/resolve1'
|
||||
MANAGER_INTERFACE = 'org.freedesktop.resolve1.Manager'
|
||||
LINK_INTERFACE = 'org.freedesktop.resolve1.Link'
|
||||
|
||||
|
||||
class DNSServer:
|
||||
"""Representation of a DNS server in systemd-resolved state."""
|
||||
|
||||
def __init__(self, link_index: int, address_class: int,
|
||||
address_ints: list[int], port: int = 0,
|
||||
domain_name: str | None = None):
|
||||
self.link_index = link_index
|
||||
self.address_class = address_class
|
||||
self.address = ipaddress.ip_address(bytes(address_ints))
|
||||
self.port = port
|
||||
self.domain_name = domain_name
|
||||
|
||||
def __str__(self):
|
||||
if self.port:
|
||||
if self.address.version == 4: # IPv4
|
||||
address_str = f'{self.address.compressed}:{self.port}'
|
||||
else: # IPv6
|
||||
address_str = f'[{self.address.compressed}]:{self.port}'
|
||||
else:
|
||||
address_str = self.address.compressed
|
||||
|
||||
if self.domain_name:
|
||||
return f'{address_str} ({self.domain_name})'
|
||||
|
||||
return address_str
|
||||
|
||||
|
||||
class Link:
|
||||
"""systemd-resolved state for a particular link or global context."""
|
||||
|
||||
def __init__(self, connection, object_path, link_index: int = 0,
|
||||
interface_name: str | None = None):
|
||||
"""Fetch all the relevant properties for a link over DBus."""
|
||||
if not link_index: # Global
|
||||
interface = MANAGER_INTERFACE
|
||||
else:
|
||||
interface = LINK_INTERFACE
|
||||
|
||||
self.proxy = gio.DBusProxy.new_sync(connection,
|
||||
gio.DBusProxyFlags.NONE, None,
|
||||
RESOLVE_NAME, object_path,
|
||||
interface)
|
||||
|
||||
self.link_index = link_index
|
||||
self.interface_name = interface_name
|
||||
|
||||
self.dns_over_tls = self.proxy.get_cached_property(
|
||||
'DNSOverTLS').unpack()
|
||||
self.dnssec = self.proxy.get_cached_property('DNSSEC').unpack()
|
||||
self.dnssec_supported = self.proxy.get_cached_property(
|
||||
'DNSSECSupported')
|
||||
|
||||
self.dns_servers = self._new_dns_servers(
|
||||
self.proxy.get_cached_property('DNSEx'))
|
||||
|
||||
self.fallback_dns_servers = None
|
||||
if not link_index:
|
||||
self.fallback_dns_servers = self._new_dns_servers(
|
||||
self.proxy.get_cached_property('FallbackDNSEx'))
|
||||
|
||||
self.current_dns_server = self._new_dns_server(
|
||||
self.proxy.get_cached_property('CurrentDNSServerEx'))
|
||||
|
||||
def get_link(self, link_index):
|
||||
"""Return a string path to a link's DBus object."""
|
||||
return self.proxy.GetLink('(i)', link_index)
|
||||
|
||||
@property
|
||||
def dns_over_tls_string(self):
|
||||
"""Return a string representation for DNS-over-TLS status."""
|
||||
value_map = {
|
||||
'yes': _('yes'),
|
||||
'opportunistic': _('opportunistic'),
|
||||
'no': _('no')
|
||||
}
|
||||
return value_map.get(self.dns_over_tls, self.dns_over_tls)
|
||||
|
||||
@property
|
||||
def dnssec_string(self):
|
||||
"""Return a string representation for DNSSEC status."""
|
||||
value_map = {
|
||||
'yes': _('yes'),
|
||||
'allow-downgrade': _('allow-downgrade'),
|
||||
'no': _('no')
|
||||
}
|
||||
return value_map.get(self.dnssec, self.dnssec)
|
||||
|
||||
@property
|
||||
def dnssec_supported_string(self):
|
||||
"""Return a string representation for whether DNSSEC is supported."""
|
||||
return _('supported') if self.dnssec_supported else _('unsupported')
|
||||
|
||||
def _new_dns_servers(self, dns_tuples):
|
||||
"""Return list of DNS Server objects from variant tuple.
|
||||
|
||||
Global DNS servers list also contains individual link DNS servers.
|
||||
Ignore those.
|
||||
"""
|
||||
return [
|
||||
self._new_dns_server(dns_tuple) for dns_tuple in dns_tuples
|
||||
if self.link_index != 0 or dns_tuple[0] == 0
|
||||
]
|
||||
|
||||
def _new_dns_server(self, dns_tuple):
|
||||
"""Return a DNS Server object from variant tuple.
|
||||
|
||||
Tuple can be prefixed by link index in case of DNS server for global
|
||||
context. Handle both cases. Entire tuple may be empty. Return None in
|
||||
that case.
|
||||
"""
|
||||
if self.link_index: # Not global
|
||||
dns_tuple = (self.link_index, ) + tuple(dns_tuple)
|
||||
|
||||
if not dns_tuple[2]: # Empty address
|
||||
return None
|
||||
|
||||
return DNSServer(*dns_tuple)
|
||||
|
||||
def __str__(self):
|
||||
dnssec_supported = ('supported'
|
||||
if self.dnssec_supported else 'unspported')
|
||||
value = ''
|
||||
if not self.link_index:
|
||||
value += 'Global\n'
|
||||
else:
|
||||
value = f'Link {self.link_index} ({self.interface_name})\n'
|
||||
|
||||
if self.current_dns_server:
|
||||
value += f' Current DNS Server: {str(self.current_dns_server)}\n'
|
||||
|
||||
if self.dns_servers:
|
||||
value += ' DNS Servers:\n'
|
||||
for server in self.dns_servers:
|
||||
value += f' {server}\n'
|
||||
|
||||
if self.fallback_dns_servers:
|
||||
value += ' Fallback DNS Servers: \n'
|
||||
for server in self.fallback_dns_servers:
|
||||
value += f' {server}\n'
|
||||
|
||||
value += f' DNS-over-TLS: {self.dns_over_tls}\n'
|
||||
value += f' DNSSEC: {self.dnssec}/{dnssec_supported}\n'
|
||||
return value
|
||||
|
||||
|
||||
def get_links():
|
||||
"""Return the list of network interfaces and their indices."""
|
||||
process = subprocess.run(['ip', '--json', 'link'], stdout=subprocess.PIPE,
|
||||
check=True)
|
||||
output = json.loads(process.stdout.decode())
|
||||
|
||||
links = {} # Maintain link index order
|
||||
for entry in output:
|
||||
links[entry['ifindex']] = entry['ifname']
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def get_status():
|
||||
"""Return the current status of systemd-resolved daemon."""
|
||||
link_status = []
|
||||
connection = gio.bus_get_sync(gio.BusType.SYSTEM)
|
||||
global_link = Link(connection, RESOLVE_PATH)
|
||||
link_status.append(global_link)
|
||||
|
||||
links = get_links()
|
||||
for link_index, interface_name in links.items():
|
||||
if interface_name == 'lo':
|
||||
continue
|
||||
|
||||
link_path = global_link.get_link(link_index)
|
||||
link_status.append(
|
||||
Link(connection, link_path, link_index, interface_name))
|
||||
|
||||
return link_status
|
||||
@ -6,7 +6,10 @@
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block configuration %}
|
||||
{% block status %}
|
||||
{{ block.super }}
|
||||
|
||||
<h3>{% trans "Domains" %}</h3>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table names-table">
|
||||
@ -50,4 +53,64 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>{% trans "Resolver Status" %}</h3>
|
||||
|
||||
{% if resolved_status %}
|
||||
<div class="table-responsive">
|
||||
<table class="table resolved-status-table">
|
||||
<tbody>
|
||||
{% for link in resolved_status %}
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
{% if link.link_index == 0 %}
|
||||
{% trans "Global" %}
|
||||
{% else %}
|
||||
{% trans "Link" %} {{ link.link_index }} ({{link.interface_name}})
|
||||
{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "DNS-over-TLS" %}</td>
|
||||
<td>{{ link.dns_over_tls_string }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "DNSSEC" %}</td>
|
||||
<td>{{ link.dnssec_string }}/{{ link.dnssec_supported_string }}</td>
|
||||
</tr>
|
||||
{% if link.current_dns_server %}
|
||||
<tr>
|
||||
<td>{% trans "Current DNS Server" %}</td>
|
||||
<td>{{ link.current_dns_server }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if link.dns_servers %}
|
||||
<tr>
|
||||
<td>{% trans "DNS Servers" %}</td>
|
||||
<td>
|
||||
{% for server in link.dns_servers %}
|
||||
{{ server }}<br />
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if link.fallback_dns_servers %}
|
||||
<tr>
|
||||
<td>{% trans "Fallback DNS Servers" %}</td>
|
||||
<td>
|
||||
{% for server in link.fallback_dns_servers %}
|
||||
{{ server }}<br />
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "Error retrieving status:" %} {{ resolved_status_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
46
plinth/modules/names/tests/test_forms.py
Normal file
46
plinth/modules/names/tests/test_forms.py
Normal file
@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Tests for forms in names app."""
|
||||
|
||||
from ..forms import DomainNameForm, HostnameForm
|
||||
|
||||
|
||||
def test_hostname_field():
|
||||
"""Test that hostname field accepts only valid hostnames."""
|
||||
valid_hostnames = [
|
||||
'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
|
||||
'012345678901234567890123456789012345678901234567890123456789012'
|
||||
]
|
||||
invalid_hostnames = [
|
||||
'', '-', '-a', 'a-', '.a', 'a.', 'a.a', '?', 'a?a',
|
||||
'0123456789012345678901234567890123456789012345678901234567890123'
|
||||
]
|
||||
|
||||
for hostname in valid_hostnames:
|
||||
form = HostnameForm({'hostname': hostname})
|
||||
assert form.is_valid()
|
||||
|
||||
for hostname in invalid_hostnames:
|
||||
form = HostnameForm({'hostname': hostname})
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
def test_domain_name_field():
|
||||
"""Test that domain name field accepts only valid domain names."""
|
||||
valid_domain_names = [
|
||||
'', 'a', '0a', 'a0', 'AAA', '00', '0-0', 'example-hostname', 'example',
|
||||
'example.org', 'a.b.c.d', 'a-0.b-0.c-0',
|
||||
'012345678901234567890123456789012345678901234567890123456789012',
|
||||
((('x' * 63) + '.') * 3) + 'x' * 61
|
||||
]
|
||||
invalid_domain_names = [
|
||||
'-', '-a', 'a-', '.a', 'a.', '?', 'a?a', 'a..a', 'a.-a', '.',
|
||||
((('x' * 63) + '.') * 3) + 'x' * 62, 'x' * 64
|
||||
]
|
||||
|
||||
for domain_name in valid_domain_names:
|
||||
form = DomainNameForm({'domain_name': domain_name})
|
||||
assert form.is_valid()
|
||||
|
||||
for domain_name in invalid_domain_names:
|
||||
form = DomainNameForm({'domain_name': domain_name})
|
||||
assert not form.is_valid()
|
||||
43
plinth/modules/names/tests/test_functional.py
Normal file
43
plinth/modules/names/tests/test_functional.py
Normal file
@ -0,0 +1,43 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Functional, browser based tests for names app."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plinth.tests import functional
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.system, pytest.mark.essential, pytest.mark.domain,
|
||||
pytest.mark.names
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', autouse=True)
|
||||
def fixture_background(session_browser):
|
||||
"""Login."""
|
||||
functional.login(session_browser)
|
||||
|
||||
|
||||
def test_change_hostname(session_browser):
|
||||
"""Test changing the hostname."""
|
||||
functional.set_hostname(session_browser, 'mybox')
|
||||
assert _get_hostname(session_browser) == 'mybox'
|
||||
|
||||
|
||||
def _get_hostname(browser):
|
||||
functional.visit(browser, '/plinth/sys/names/hostname/')
|
||||
return browser.find_by_id('id_hostname-hostname').value
|
||||
|
||||
|
||||
def test_change_domain_name(session_browser):
|
||||
"""Test changing the domain name."""
|
||||
functional.set_domain_name(session_browser, 'mydomain.example')
|
||||
assert _get_domain_name(session_browser) == 'mydomain.example'
|
||||
|
||||
# Capitalization is ignored.
|
||||
functional.set_domain_name(session_browser, 'Mydomain.example')
|
||||
assert _get_domain_name(session_browser) == 'mydomain.example'
|
||||
|
||||
|
||||
def _get_domain_name(browser):
|
||||
functional.visit(browser, '/plinth/sys/names/domains/')
|
||||
return browser.find_by_id('id_domain-name-domain_name').value
|
||||
@ -40,4 +40,4 @@ def test_on_domain_removed():
|
||||
# try to remove things that don't exist
|
||||
on_domain_removed('', '')
|
||||
with pytest.raises(KeyError):
|
||||
on_domain_removed('', 'domainname', 'iiiii')
|
||||
on_domain_removed('', 'some-domain-type', 'x-unknown-name')
|
||||
|
||||
@ -9,4 +9,8 @@ from . import views
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^sys/names/$', views.NamesAppView.as_view(), name='index'),
|
||||
re_path(r'^sys/names/hostname/$', views.HostnameView.as_view(),
|
||||
name='hostname'),
|
||||
re_path(r'^sys/names/domains/$', views.DomainNameView.as_view(),
|
||||
name='domains'),
|
||||
]
|
||||
|
||||
@ -3,9 +3,21 @@
|
||||
FreedomBox app for name services.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from plinth.modules import names
|
||||
from plinth.signals import domain_added, domain_removed
|
||||
from plinth.views import AppView
|
||||
|
||||
from . import components
|
||||
from . import components, privileged, resolved
|
||||
from .forms import DomainNameForm, HostnameForm, NamesConfigurationForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NamesAppView(AppView):
|
||||
@ -13,13 +25,112 @@ class NamesAppView(AppView):
|
||||
|
||||
app_id = 'names'
|
||||
template_name = 'names.html'
|
||||
prefix = 'names'
|
||||
form_class = NamesConfigurationForm
|
||||
|
||||
def get_initial(self):
|
||||
"""Return the values to fill in the form."""
|
||||
initial = super().get_initial()
|
||||
initial.update(privileged.get_resolved_configuration())
|
||||
return initial
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
"""Add additional context data for template."""
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['status'] = get_status()
|
||||
try:
|
||||
context['resolved_status'] = resolved.get_status()
|
||||
except Exception as exception:
|
||||
context['resolved_status_error'] = exception
|
||||
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Apply the changes submitted in the form."""
|
||||
old_data = form.initial
|
||||
form_data = form.cleaned_data
|
||||
|
||||
changes = {}
|
||||
if old_data['dns_over_tls'] != form_data['dns_over_tls']:
|
||||
changes['dns_over_tls'] = form_data['dns_over_tls']
|
||||
|
||||
if old_data['dnssec'] != form_data['dnssec']:
|
||||
changes['dnssec'] = form_data['dnssec']
|
||||
|
||||
if changes:
|
||||
privileged.set_resolved_configuration(**changes)
|
||||
messages.success(self.request, _('Configuration updated'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class HostnameView(FormView):
|
||||
"""View to update system's hostname."""
|
||||
template_name = 'form.html'
|
||||
form_class = HostnameForm
|
||||
prefix = 'hostname'
|
||||
success_url = reverse_lazy('names:index')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Return additional context for rendering the template."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _('Set Hostname')
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
"""Return the values to fill in the form."""
|
||||
initial = super().get_initial()
|
||||
initial['hostname'] = names.get_hostname()
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Apply the form changes."""
|
||||
if form.initial['hostname'] != form.cleaned_data['hostname']:
|
||||
try:
|
||||
names.set_hostname(form.cleaned_data['hostname'])
|
||||
messages.success(self.request, _('Configuration updated'))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('Error setting hostname: {exception}').format(
|
||||
exception=exception))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class DomainNameView(FormView):
|
||||
"""View to update system's static domain name."""
|
||||
template_name = 'form.html'
|
||||
form_class = DomainNameForm
|
||||
prefix = 'domain-name'
|
||||
success_url = reverse_lazy('names:index')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Return additional context for rendering the template."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _('Set Domain Name')
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
"""Return the values to fill in the form."""
|
||||
initial = super().get_initial()
|
||||
initial['domain_name'] = names.get_domain_name()
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Apply the form changes."""
|
||||
if form.initial['domain_name'] != form.cleaned_data['domain_name']:
|
||||
try:
|
||||
set_domain_name(form.cleaned_data['domain_name'])
|
||||
messages.success(self.request, _('Configuration updated'))
|
||||
except Exception as exception:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('Error setting domain name: {exception}').format(
|
||||
exception=exception))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def get_status():
|
||||
"""Get configured services per name."""
|
||||
@ -31,3 +142,26 @@ def get_status():
|
||||
]
|
||||
|
||||
return {'domains': domains, 'unused_domain_types': unused_domain_types}
|
||||
|
||||
|
||||
def set_domain_name(domain_name):
|
||||
"""Set system's static domain name to domain_name."""
|
||||
old_domain_name = names.get_domain_name()
|
||||
|
||||
# Domain name is not case sensitive, but Let's Encrypt certificate
|
||||
# paths use lower-case domain name.
|
||||
domain_name = domain_name.lower()
|
||||
|
||||
logger.info('Changing domain name to - %s', domain_name)
|
||||
privileged.set_domain_name(domain_name)
|
||||
|
||||
# Update domain registered with Name Services module.
|
||||
if old_domain_name:
|
||||
domain_removed.send_robust(sender='names',
|
||||
domain_type='domain-type-static',
|
||||
name=old_domain_name)
|
||||
|
||||
if domain_name:
|
||||
domain_added.send_robust(sender='names',
|
||||
domain_type='domain-type-static',
|
||||
name=domain_name, services='__all__')
|
||||
|
||||
@ -10,6 +10,7 @@ from plinth import app as app_module
|
||||
from plinth import daemon, kvstore, menu, network
|
||||
from plinth.config import DropinConfigs
|
||||
from plinth.diagnostic_check import DiagnosticCheck
|
||||
from plinth.modules.firewall.components import Firewall
|
||||
from plinth.package import Packages
|
||||
|
||||
from . import privileged
|
||||
@ -69,6 +70,11 @@ class NetworksApp(app_module.App):
|
||||
packages = Packages('packages-networks', ['network-manager', 'batctl'])
|
||||
self.add(packages)
|
||||
|
||||
# For 'shared' network connections
|
||||
firewall = Firewall('firewall-networks', info.name,
|
||||
ports=['dns', 'dhcp'], is_external=False)
|
||||
self.add(firewall)
|
||||
|
||||
dropin_configs = DropinConfigs('dropin-configs-networks', [
|
||||
'/etc/NetworkManager/dispatcher.d/10-freedombox-batman',
|
||||
])
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from plinth import cfg, network
|
||||
@ -10,6 +12,22 @@ from plinth.utils import format_lazy, import_from_gi
|
||||
nm = import_from_gi('NM', '1.0')
|
||||
|
||||
|
||||
def _get_dns_over_tls():
|
||||
"""Return the value of DNS over TLS."""
|
||||
try:
|
||||
from plinth.modules.names import privileged
|
||||
dns_over_tls = privileged.get_resolved_configuration()['dns_over_tls']
|
||||
except Exception:
|
||||
return _('unknown')
|
||||
|
||||
value_map = {
|
||||
'yes': _('yes'),
|
||||
'opportunistic': _('opportunistic'),
|
||||
'no': _('no')
|
||||
}
|
||||
return str(value_map.get(dns_over_tls, dns_over_tls))
|
||||
|
||||
|
||||
class ConnectionTypeSelectForm(forms.Form):
|
||||
"""Form to select type for new connection."""
|
||||
connection_type = forms.ChoiceField(
|
||||
@ -30,6 +48,40 @@ class ConnectionForm(forms.Form):
|
||||
help_text=_('The firewall zone will control which services are '
|
||||
'available over this interfaces. Select Internal only '
|
||||
'for trusted networks.'), choices=network.ZONES)
|
||||
dns_over_tls = forms.ChoiceField(
|
||||
label=_('Use DNS-over-TLS'), widget=forms.RadioSelect, choices=[
|
||||
('default',
|
||||
format_lazy(
|
||||
'Default. Unspecified for this connection. <p '
|
||||
'class="help-block">Use the <a href="{names_app}">global '
|
||||
'preference</a>. Current value is "{global_value}".</p>',
|
||||
names_app=reverse_lazy('names:index'),
|
||||
global_value=lazy(_get_dns_over_tls,
|
||||
str)(), allow_markup=True)),
|
||||
('yes',
|
||||
format_lazy(
|
||||
'Yes. Encrypt connections to the DNS server. <p '
|
||||
'class="help-block">This improves privacy as domain name '
|
||||
'queries will not be made as plain text over the network. It '
|
||||
'also improves security as responses from the server cannot '
|
||||
'be manipulated. If the configured DNS servers do not '
|
||||
'support DNS-over-TLS, all name resolutions will fail. If '
|
||||
'your DNS provider (likely your ISP) does not support '
|
||||
'DNS-over-TLS or blocks some domains, you can configure a '
|
||||
'well-known public DNS server below.</p>',
|
||||
allow_markup=True)),
|
||||
('opportunistic',
|
||||
format_lazy(
|
||||
'Opportunistic. <p class="help-block">Encrypt connections to '
|
||||
'the DNS server if the server supports DNS-over-TLS. '
|
||||
'Otherwise, use unencrypted connections. There is no '
|
||||
'protection against response manipulation.</p>',
|
||||
allow_markup=True)),
|
||||
('no',
|
||||
format_lazy(
|
||||
'No. <p class="help-block">Do not encrypt domain name '
|
||||
'resolutions for this connection.</p>', allow_markup=True)),
|
||||
], initial='default')
|
||||
ipv4_method = forms.ChoiceField(
|
||||
label=_('IPv4 Addressing Method'), widget=forms.RadioSelect, choices=[
|
||||
('auto',
|
||||
@ -76,11 +128,15 @@ class ConnectionForm(forms.Form):
|
||||
('dhcp',
|
||||
_('Automatic (DHCP only): Configure automatically, use Internet '
|
||||
'connection from this network')),
|
||||
('link-local',
|
||||
_('Link-local: Configure automatically to use an address that is '
|
||||
'only relevant to this network.')),
|
||||
('manual',
|
||||
_('Manual: Use manually specified parameters, use Internet '
|
||||
'connection from this network')),
|
||||
('ignore', _('Ignore: Ignore this addressing method')),
|
||||
])
|
||||
('disabled', _('Disabled: Disable IPv6 for this connection')),
|
||||
], initial='auto')
|
||||
ipv6_address = forms.CharField(
|
||||
label=_('Address'), validators=[validators.validate_ipv6_address],
|
||||
required=False)
|
||||
@ -127,6 +183,7 @@ class ConnectionForm(forms.Form):
|
||||
'name': self.cleaned_data['name'],
|
||||
'interface': self.cleaned_data['interface'],
|
||||
'zone': self.cleaned_data['zone'],
|
||||
'dns_over_tls': self.cleaned_data['dns_over_tls'],
|
||||
}
|
||||
settings['ipv4'] = self.get_ipv4_settings()
|
||||
settings['ipv6'] = self.get_ipv6_settings()
|
||||
@ -191,6 +248,7 @@ class EthernetForm(ConnectionForm):
|
||||
|
||||
class PPPoEForm(EthernetForm):
|
||||
"""Form to create a new PPPoE connection."""
|
||||
dns_over_tls = None
|
||||
ipv4_method = None
|
||||
ipv4_address = None
|
||||
ipv4_netmask = None
|
||||
@ -231,14 +289,6 @@ class PPPoEForm(EthernetForm):
|
||||
|
||||
class WifiForm(ConnectionForm):
|
||||
"""Form to create/edit a Wi-Fi connection."""
|
||||
field_order = [
|
||||
'name', 'interface', 'zone', 'ssid', 'mode', 'band', 'channel',
|
||||
'bssid', 'auth_mode', 'passphrase', 'ipv4_method', 'ipv4_address',
|
||||
'ipv4_netmask', 'ipv4_gateway', 'ipv4_dns', 'ipv4_second_dns',
|
||||
'ipv6_method', 'ipv6_address', 'ipv6_prefix', 'ipv6_gateway',
|
||||
'ipv6_dns', 'ipv6_second_dns'
|
||||
]
|
||||
|
||||
ssid = forms.CharField(label=_('SSID'),
|
||||
help_text=_('The visible name of the network.'))
|
||||
mode = forms.ChoiceField(
|
||||
|
||||
@ -98,3 +98,21 @@ jQuery(function($) {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// When there are validation errors on form elements, expand their parent
|
||||
// collapsible so that the form element can be highlighted and an error tooltip
|
||||
// can be show by the browser.
|
||||
document.addEventListener('DOMContentLoaded', event => {
|
||||
const selector = '.form-connection input, .form-connection select';
|
||||
const input_elements = document.querySelectorAll(selector);
|
||||
input_elements.forEach(input =>
|
||||
input.addEventListener('invalid', on_invalid_event)
|
||||
);
|
||||
});
|
||||
|
||||
function on_invalid_event(event) {
|
||||
const element = event.target;
|
||||
const parent = element.closest('.collapse');
|
||||
// Don't use .collapse(). Instead, expand all the sections with errors.
|
||||
parent.classList.add('show');
|
||||
}
|
||||
|
||||
@ -256,6 +256,15 @@
|
||||
<p>{% trans "This connection is not active." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>{% trans "Privacy" %}</h3>
|
||||
|
||||
<div class="list-group list-group-two-column">
|
||||
<div class="list-group-item">
|
||||
<span class="primary">{% trans "DNS-over-TLS" %}</span>
|
||||
<span class="secondary">{{ connection.dns_over_tls_string }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{% trans "Security" %}</h3>
|
||||
|
||||
{% if connection.zone == "internal" %}
|
||||
|
||||
@ -3,17 +3,16 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>{{ title }}</h3>
|
||||
|
||||
<form class="form form-connection-create" method="post">
|
||||
<form class="form form-connection form-connection-create" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|bootstrap }}
|
||||
{% include "connections_fields.html" %}
|
||||
|
||||
<input type="submit" class="btn btn-primary"
|
||||
value="{% trans "Create Connection" %}"/>
|
||||
|
||||
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