---
plinth/modules/networks/forms.py | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/plinth/modules/networks/forms.py b/plinth/modules/networks/forms.py
index 7b6fa384c..d16059e51 100644
--- a/plinth/modules/networks/forms.py
+++ b/plinth/modules/networks/forms.py
@@ -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(
@@ -35,8 +53,11 @@ class ConnectionForm(forms.Form):
('default',
format_lazy(
'Default. Unspecified for this connection. Use the global preference.
',
- allow_markup=True)),
+ 'class="help-block">Use the global '
+ 'preference. Current value is "{global_value}".',
+ 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.
Date: Sat, 7 Sep 2024 10:05:37 -0400
Subject: [PATCH 31/78] letsencrypt: Handle both standard and custom repairs
Pass remaining failed checks to super.
Tests:
- Remove /etc/letsencrypt/renewal-hooks/deploy/50-freedombox so that
the diagnostic fails. Running repair causes the file to be
re-created.
- Set domain name to non-existing domain so that the diagnostic
fails. Running repair attempts to obtain the certificate.
- Have both diagnostics failing. Running repair will attempt to repair
both.
Signed-off-by: James Valleroy
Reviewed-by: Sunil Mohan Adapa
---
plinth/modules/letsencrypt/__init__.py | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py
index 39c7014af..0066385cf 100644
--- a/plinth/modules/letsencrypt/__init__.py
+++ b/plinth/modules/letsencrypt/__init__.py
@@ -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."""
From a1070bf3197a41f37ba105f79cafd28c65939e90 Mon Sep 17 00:00:00 2001
From: Sunil Mohan Adapa
Date: Thu, 5 Sep 2024 18:28:08 -0700
Subject: [PATCH 32/78] names: Show systemd-resolved status in the names page
This improves the user experience in many ways:
- Help user understand if DNSSEC is being used on the current DNS server in case
'allow-fallback' is supported.
- Nudges the user to explore enabling DNS-over-TLS and DNSSEC.
- Help user understand how global vs. link specific configuration works. Help
user understand if a global DNS is being used.
- Show the list of fallback DNS servers being used (as this poses privacy
concerns).
Also helps with debugging in problematic situations:
- Find out which DNS server is being used (and leading to problems) and show the
cycling mechanism.
Tests:
- Enable/disable fallback DNS server in privacy app. See that fallback servers
line is only shown when enabled.
- Set various global values of DNS-over-TLS and DNSSEC and see the status
changes.
- Set various values of DNS-over-TLS in the network connection settings and see
the changes in status.
- Set DNSSEC to allow-fallback. Perform a query and see that the value of
supported/unsupported changes.
- Set DNS servers with special configuration file in
/etc/systemd/resolved.conf.d/test.conf and restart systemd-resolved. See change
in status page. Notice that if connection specific DNS server is set to an
invalid server, global section has a current DNS server.
- Set SNI domain name and port for the an IPv4 DNS and an IPv6 DNS. See that the
display is as expected.
- Raise an exception in get_status() and notice that an error alert is show
properly.
Signed-off-by: Sunil Mohan Adapa
Reviewd-by: Veiko Aasa
---
plinth/modules/names/__init__.py | 3 +-
plinth/modules/names/resolved.py | 194 ++++++++++++++++++++++
plinth/modules/names/templates/names.html | 60 +++++++
plinth/modules/names/views.py | 7 +-
4 files changed, 262 insertions(+), 2 deletions(-)
create mode 100644 plinth/modules/names/resolved.py
diff --git a/plinth/modules/names/__init__.py b/plinth/modules/names/__init__.py
index 2e5e46773..9ef138332 100644
--- a/plinth/modules/names/__init__.py
+++ b/plinth/modules/names/__init__.py
@@ -57,8 +57,9 @@ 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'])
+ ['systemd-resolved', 'libnss-resolve', 'iproute2'])
self.add(packages)
daemon = Daemon('daemon-names', 'systemd-resolved')
diff --git a/plinth/modules/names/resolved.py b/plinth/modules/names/resolved.py
new file mode 100644
index 000000000..531da83d1
--- /dev/null
+++ b/plinth/modules/names/resolved.py
@@ -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
diff --git a/plinth/modules/names/templates/names.html b/plinth/modules/names/templates/names.html
index 2ba35f9a4..6c54a6ad2 100644
--- a/plinth/modules/names/templates/names.html
+++ b/plinth/modules/names/templates/names.html
@@ -53,4 +53,64 @@