import os import abc import logging import re import textwrap import time from ipaplatform.paths import paths logger = logging.getLogger(__name__) class Resolver(abc.ABC): def __init__(self, host): self.host = host self.backups = [] self.current_state = self._get_state() logger.info('Obtained initial resolver state for host %s: %s', self.host, self.current_state) def setup_resolver(self, nameservers, searchdomains=None): """Configure DNS resolver :param nameservers: IP address of nameserver or a list of addresses :param searchdomains: searchdomain or list of searchdomains. None - do not configure Resolver.backup() must be called prior to using this method. Raises exception if configuration was changed externally since last call to any method of Resolver class. """ if len(self.backups) == 0: raise Exception( 'Changing resolver state without backup is forbidden') self.check_state_expected() if isinstance(nameservers, str): nameservers = [nameservers] if isinstance(searchdomains, str): searchdomains = [searchdomains] if searchdomains is None: searchdomains = [] logger.info( 'Setting up resolver for host %s: nameservers=%s, searchdomains=%s', self.host, nameservers, searchdomains ) state = self._make_state_from_args(nameservers, searchdomains) self._set_state(state) def backup(self): """Saves current configuration to stack Raises exception if configuration was changed externally since last call to any method of Resolver class. """ self.check_state_expected() self.backups.append(self._get_state()) logger.info( 'Saved resolver state for host %s, number of saved states: %s', self.host, len(self.backups) ) def restore(self): """Restore configuration from stack of backups. Raises exception if configuration was changed externally since last call to any method of Resolver class. """ if len(self.backups) == 0: raise Exception('No resolver backups found for host {}'.format( self.host)) self.check_state_expected() self._set_state(self.backups.pop()) logger.info( 'Restored resolver state for host %s, number of saved states: %s', self.host, len(self.backups) ) def has_backups(self): """Checks if stack of backups is not empty""" return bool(self.backups) def check_state_expected(self): """Checks if resolver configuration has not changed. Raises AssertionError if actual configuration has changed since last call to any method of Resolver """ assert self._get_state() == self.current_state, ( 'Resolver state changed unexpectedly at host {}'.format(self.host)) def __enter__(self): self.backup() return self def __exit__(self, exc_type, exc_val, exc_tb): self.restore() def _set_state(self, state): self._apply_state(state) logger.info('Applying resolver state for host %s: %s', self.host, state) self.current_state = state @classmethod @abc.abstractmethod def is_our_resolver(cls, host): """Checks if the class is appropriate for managing resolver on the host. """ @abc.abstractmethod def _make_state_from_args(self, nameservers, searchdomains): """ :param nameservers: list of ip addresses of nameservers :param searchdomains: list of searchdomain, can be an empty list :return: internal state object specific to subclass implementaion """ @abc.abstractmethod def _get_state(self): """Acquire actual host configuration. :return: internal state object specific to subclass implementaion """ @abc.abstractmethod def _apply_state(self, state): """Apply configuration to host. :param state: internal state object specific to subclass implementaion """ def uses_localhost_as_dns(self): """Return true if the localhost is set as DNS server. Default implementation checks the content of /etc/resolv.conf """ resolvconf = self.host.get_file_contents(paths.RESOLV_CONF, 'utf-8') patterns = [r"^\s*nameserver\s+127\.0\.0\.1\s*$", r"^\s*nameserver\s+::1\s*$"] return any(re.search(p, resolvconf, re.MULTILINE) for p in patterns) class ResolvedResolver(Resolver): RESOLVED_RESOLV_CONF = { "/run/systemd/resolve/stub-resolv.conf", "/run/systemd/resolve/resolv.conf", "/lib/systemd/resolv.conf", "/usr/lib/systemd/resolv.conf", } RESOLVED_CONF_FILE = ( '/etc/systemd/resolved.conf.d/zzzz-ipatests-nameservers.conf') RESOLVED_CONF = textwrap.dedent(''' # generated by IPA tests [Resolve] DNS={nameservers} Domains=~. {searchdomains} ''') @classmethod def is_our_resolver(cls, host): res = host.run_command( ['stat', '--format', '%F', paths.RESOLV_CONF]) filetype = res.stdout_text.strip() if filetype == 'symbolic link': res = host.run_command(['realpath', paths.RESOLV_CONF]) return (res.stdout_text.strip() in cls.RESOLVED_RESOLV_CONF) return False def _restart_resolved(self): # Restarting service at rapid pace (which is what happens in some test # scenarios) can exceed the threshold configured in systemd option # StartLimitIntervalSec. In that case restart fails, but we can simply # continue trying until it succeeds from . import tasks # pylint: disable=cyclic-import tasks.run_repeatedly( self.host, ['systemctl', 'restart', 'systemd-resolved.service'], timeout=15) def _make_state_from_args(self, nameservers, searchdomains): return { 'resolved_config': self.RESOLVED_CONF.format( nameservers=' '.join(nameservers), searchdomains=' '.join(searchdomains)) } def _get_state(self): exists = self.host.transport.file_exists(self.RESOLVED_CONF_FILE) return { 'resolved_config': self.host.get_file_contents(self.RESOLVED_CONF_FILE, 'utf-8') if exists else None } def _apply_state(self, state): if state['resolved_config'] is None: self.host.run_command(['rm', '-f', self.RESOLVED_CONF_FILE]) else: self.host.run_command( ['mkdir', '-p', os.path.dirname(self.RESOLVED_CONF_FILE)]) self.host.put_file_contents( self.RESOLVED_CONF_FILE, state['resolved_config']) self._restart_resolved() def uses_localhost_as_dns(self): """Return true if the localhost is set as DNS server. When systemd-resolved is in use, the DNS can be found using the command resolvectldns. """ dnsconf = self.host.run_command(['resolvectl', 'dns']).stdout_text patterns = [r"^Global:.*\s+127.0.0.1\s+.*$", r"^Global:.*\s+::1\s+.*$"] return any(re.search(p, dnsconf, re.MULTILINE) for p in patterns) class PlainFileResolver(Resolver): IPATESTS_RESOLVER_COMMENT = '# created by ipatests' @classmethod def is_our_resolver(cls, host): res = host.run_command( ['stat', '--format', '%F', paths.RESOLV_CONF]) filetype = res.stdout_text.strip() if filetype == 'regular file': # We want to be sure that /etc/resolv.conf is not generated # by NetworkManager or systemd-resolved. When it is then # the first line of the file is a comment of the form: # # Generated by NetworkManager # # or # # This file is managed by man:systemd-resolved(8). Do not edit. # # So we check that either first line of resolv.conf # is not a comment or the comment does not mention NM or # systemd-resolved resolv_conf = host.get_file_contents(paths.RESOLV_CONF, 'utf-8') line = resolv_conf.splitlines()[0].strip() return not line.startswith('#') or all([ 'resolved' not in line, 'NetworkManager' not in line ]) return False def _make_state_from_args(self, nameservers, searchdomains): contents_lines = [self.IPATESTS_RESOLVER_COMMENT] contents_lines.extend('nameserver {}'.format(r) for r in nameservers) if searchdomains: contents_lines.append('search {}'.format(' '.join(searchdomains))) contents = '\n'.join(contents_lines) return {'resolv_conf': contents} def _get_state(self): return { 'resolv_conf': self.host.get_file_contents( paths.RESOLV_CONF, 'utf-8') } def _apply_state(self, state): self.host.put_file_contents(paths.RESOLV_CONF, state['resolv_conf']) class NetworkManagerResolver(Resolver): NM_CONF_FILE = '/etc/NetworkManager/conf.d/zzzz-ipatests.conf' NM_CONF = textwrap.dedent(''' # generated by IPA tests [main] dns=default [global-dns] searches={searchdomains} [global-dns-domain-*] servers={nameservers} ''') @classmethod def is_our_resolver(cls, host): res = host.run_command( ['stat', '--format', '%F', paths.RESOLV_CONF]) filetype = res.stdout_text.strip() if filetype == 'regular file': resolv_conf = host.get_file_contents(paths.RESOLV_CONF, 'utf-8') return resolv_conf.startswith('# Generated by NetworkManager') return False def _restart_network_manager(self): # Restarting service at rapid pace (which is what happens in some test # scenarios) can exceed the threshold configured in systemd option # StartLimitIntervalSec. In that case restart fails, but we can simply # continue trying until it succeeds from . import tasks # pylint: disable=cyclic-import tasks.run_repeatedly( self.host, ['systemctl', 'restart', 'NetworkManager.service'], timeout=15) def _make_state_from_args(self, nameservers, searchdomains): return {'nm_config': self.NM_CONF.format( nameservers=','.join(nameservers), searchdomains=','.join(searchdomains))} def _get_state(self): exists = self.host.transport.file_exists(self.NM_CONF_FILE) return { 'nm_config': self.host.get_file_contents(self.NM_CONF_FILE, 'utf-8') if exists else None } def _apply_state(self, state): def get_resolv_conf_mtime(): """Get mtime of /etc/resolv.conf. Returns mtime with sub-second precision as a string with format "2020-08-25 14:35:05.980503425 +0200" """ return self.host.run_command( ['stat', '-c', '%y', paths.RESOLV_CONF]).stdout_text.strip() if state['nm_config'] is None: self.host.run_command(['rm', '-f', self.NM_CONF_FILE]) else: self.host.run_command( ['mkdir', '-p', os.path.dirname(self.NM_CONF_FILE)]) self.host.put_file_contents( self.NM_CONF_FILE, state['nm_config']) # NetworkManager writes /etc/resolv.conf few moments after # `systemctl restart` returns so we need to wait until the file is # updated mtime_before = get_resolv_conf_mtime() self._restart_network_manager() wait_until = time.time() + 10 while time.time() < wait_until: if get_resolv_conf_mtime() != mtime_before: break time.sleep(1) else: raise Exception('NetworkManager did not update /etc/resolv.conf ' 'in 10 seconds after restart') def resolver(host): for cls in [ResolvedResolver, NetworkManagerResolver, PlainFileResolver]: if cls.is_our_resolver(host): logger.info('Detected DNS resolver manager for host %s is %s', host.hostname, cls) return cls(host) raise Exception('Resolver manager could not be detected')