From a1070bf3197a41f37ba105f79cafd28c65939e90 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 5 Sep 2024 18:28:08 -0700 Subject: [PATCH] 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 @@ +

{% trans "Resolver Status" %}

+ + {% if resolved_status %} +
+ + + {% for link in resolved_status %} + + + + + + + + + + + + {% if link.current_dns_server %} + + + + + {% endif %} + {% if link.dns_servers %} + + + + + {% endif %} + {% if link.fallback_dns_servers %} + + + + + {% endif %} + {% endfor %} + +
+ {% if link.link_index == 0 %} + {% trans "Global" %} + {% else %} + {% trans "Link" %} {{ link.link_index }} ({{link.interface_name}}) + {% endif %} +
{% trans "DNS-over-TLS" %}{{ link.dns_over_tls_string }}
{% trans "DNSSEC" %}{{ link.dnssec_string }}/{{ link.dnssec_supported_string }}
{% trans "Current DNS Server" %}{{ link.current_dns_server }}
{% trans "DNS Servers" %} + {% for server in link.dns_servers %} + {{ server }}
+ {% endfor %} +
{% trans "Fallback DNS Servers" %} + {% for server in link.fallback_dns_servers %} + {{ server }}
+ {% endfor %} +
+
+ {% else %} +
+ {% trans "Error retrieving status:" %} {{ resolved_status_error }} +
+ {% endif %} + {% endblock %} diff --git a/plinth/modules/names/views.py b/plinth/modules/names/views.py index af5990b37..72a34ee2e 100644 --- a/plinth/modules/names/views.py +++ b/plinth/modules/names/views.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from plinth.views import AppView -from . import components, privileged +from . import components, privileged, resolved from .forms import NamesConfigurationForm @@ -30,6 +30,11 @@ class NamesAppView(AppView): """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):