mirror of
https://salsa.debian.org/freeipa-team/freeipa.git
synced 2024-12-27 09:21:59 -06:00
4352bd5a50
Most of `cyclic-import` issues reported by Pylint are false-positive and they are already handled in the code, but several ones are the actual errors. Fixes: https://pagure.io/freeipa/issue/9232 Fixes: https://pagure.io/freeipa/issue/9278 Signed-off-by: Stanislav Levin <slev@altlinux.org> Reviewed-By: Stanislav Levin <slev@altlinux.org>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
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')
|