summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--ipa-client/man/default.conf.59
-rw-r--r--ipalib/constants.py1
-rw-r--r--ipalib/errors.py18
-rw-r--r--ipalib/plugins/dns.py217
4 files changed, 241 insertions, 4 deletions
diff --git a/ipa-client/man/default.conf.5 b/ipa-client/man/default.conf.5
index 5d5a48db6..c1ccf109e 100644
--- a/ipa-client/man/default.conf.5
+++ b/ipa-client/man/default.conf.5
@@ -178,6 +178,15 @@ Used internally in the IPA source package to verify that the API has not changed
.B verbose <boolean>
When True provides more information. Specifically this sets the global log level to "info".
.TP
+.B wait_for_dns <number of attempts>
+Controls whether the IPA commands dnsrecord\-{add,mod,del} work synchronously or not. The DNS commands will repeat DNS queries up to the specified number of attempts until the DNS server returns an up-to-date answer to a query for modified records. Delay between retries is one second.
+.IP
+The DNS commands will raise a DNSDataMismatch exception if the answer doesn't match the expected value even after the specified number of attempts.
+.IP
+The DNS queries will be sent to the resolver configured in /etc/resolv.conf on the IPA server.
+.IP
+Do not enable this in production! This will cause problems if the resolver on IPA server uses a caching server instead of a local authoritative server or e.g. if DNS answers are modified by DNS64. The default is disabled (the option is not present).
+.TP
.B xmlrpc_uri <URI>
Specifies the URI of the XML\-RPC server for a client. This may be used by IPA, and is used by some external tools, such as ipa\-getcert. Example: https://ipa.example.com/ipa/xml
.TP
diff --git a/ipalib/constants.py b/ipalib/constants.py
index 8fc04afcd..6cc50eacf 100644
--- a/ipalib/constants.py
+++ b/ipalib/constants.py
@@ -139,6 +139,7 @@ DEFAULT_CONFIG = (
('debug', False),
('startup_traceback', False),
('mode', 'production'),
+ ('wait_for_dns', False),
# CA plugin:
('ca_host', FQDN), # Set in Env._finalize_core()
diff --git a/ipalib/errors.py b/ipalib/errors.py
index 716decb2b..311127f62 100644
--- a/ipalib/errors.py
+++ b/ipalib/errors.py
@@ -1512,6 +1512,24 @@ class DatabaseTimeout(DatabaseError):
format = _('LDAP timeout')
+class DNSDataMismatch(ExecutionError):
+ """
+ **4212** Raised when an DNS query didn't return expected answer
+ in a configured time limit.
+
+ For example:
+
+ >>> raise DNSDataMismatch(expected="zone3.test. 86400 IN A 192.0.2.1", \
+ got="zone3.test. 86400 IN A 192.168.1.1")
+ Traceback (most recent call last):
+ ...
+ DNSDataMismatch: DNS check failed: Expected {zone3.test. 86400 IN A 192.0.2.1} got {zone3.test. 86400 IN A 192.168.1.1}
+ """
+
+ errno = 4212
+ format = _('DNS check failed: Expected {%(expected)s} got {%(got)s}')
+
+
class CertificateError(ExecutionError):
"""
**4300** Base class for Certificate execution errors (*4300 - 4399*).
diff --git a/ipalib/plugins/dns.py b/ipalib/plugins/dns.py
index c1b1b6434..876f37619 100644
--- a/ipalib/plugins/dns.py
+++ b/ipalib/plugins/dns.py
@@ -24,6 +24,7 @@ import netaddr
import time
import re
import dns.name
+import dns.resolver
from ipalib.request import context
from ipalib import api, errors, output
@@ -248,6 +249,12 @@ _record_attributes = [str('%srecord' % t.lower()) for t in _record_types]
# supported DNS classes, IN = internet, rest is almost never used
_record_classes = (u'IN', u'CS', u'CH', u'HS')
+# IN record class
+_IN = dns.rdataclass.IN
+
+# NS record type
+_NS = dns.rdatatype.from_text('NS')
+
def _rname_validator(ugettext, zonemgr):
try:
validate_zonemgr(zonemgr)
@@ -2397,6 +2404,178 @@ class dnsrecord(LDAPObject):
'NS record except when located in a zone root '
'record (RFC 6672, section 2.3)'))
+ def _entry2rrsets(self, entry_attrs, dns_name, dns_domain):
+ '''Convert entry_attrs to a dictionary {rdtype: rrset}.
+
+ :returns:
+ None if entry_attrs is None
+ {rdtype: None} if RRset of given type is empty
+ {rdtype: RRset} if RRset of given type is non-empty
+ '''
+ record_attr_suf = 'record'
+ ldap_rrsets = {}
+
+ if not entry_attrs:
+ # all records were deleted => name should not exist in DNS
+ return None
+
+ for attr, value in entry_attrs.iteritems():
+ if not attr.endswith(record_attr_suf):
+ continue
+
+ rdtype = dns.rdatatype.from_text(attr[0:-len(record_attr_suf)])
+ if not value:
+ ldap_rrsets[rdtype] = None # RRset is empty
+ continue
+
+ try:
+ # TTL here can be arbitrary value because it is ignored
+ # during comparison
+ ldap_rrset = dns.rrset.from_text(
+ dns_name, 86400, dns.rdataclass.IN, rdtype,
+ *map(str, value))
+
+ # make sure that all names are absolute so RRset
+ # comparison will work
+ for ldap_rr in ldap_rrset:
+ ldap_rr.choose_relativity(origin=dns_domain,
+ relativize=False)
+ ldap_rrsets[rdtype] = ldap_rrset
+
+ except dns.exception.SyntaxError as e:
+ self.log.error('DNS syntax error: %s %s %s: %s', dns_name,
+ dns.rdatatype.to_text(rdtype), value, e)
+ raise
+
+ return ldap_rrsets
+
+ def wait_for_modified_attr(self, ldap_rrset, rdtype, dns_name):
+ '''Wait until DNS resolver returns up-to-date answer for given RRset
+ or until the maximum number of attempts is reached.
+ Number of attempts is controlled by self.api.env['wait_for_dns'].
+
+ :param ldap_rrset:
+ None if given rdtype should not exist or
+ dns.rrset.RRset to match against data in DNS.
+ :param dns_name: FQDN to query
+ :type dns_name: dns.name.Name
+ :return: None if data in DNS and LDAP match
+ :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+ :raises dns.exception.DNSException: if DNS resolution failed
+ '''
+ resolver = dns.resolver.Resolver()
+ resolver.set_flags(0) # disable recursion (for NS RR checks)
+ max_attempts = int(self.api.env['wait_for_dns'])
+ warn_attempts = max_attempts / 2
+ period = 1 # second
+ attempt = 0
+ log_fn = self.log.debug
+ log_fn('querying DNS server: expecting answer {%s}', ldap_rrset)
+ wait_template = 'waiting for DNS answer {%s}: got {%s} (attempt %s); '\
+ 'waiting %s seconds before next try'
+
+ while attempt < max_attempts:
+ if attempt >= warn_attempts:
+ log_fn = self.log.warn
+ attempt += 1
+ try:
+ dns_answer = resolver.query(dns_name, rdtype,
+ dns.rdataclass.IN,
+ raise_on_no_answer=False)
+ dns_rrset = None
+ if rdtype == _NS:
+ # NS records can be in Authority section (sometimes)
+ dns_rrset = dns_answer.response.get_rrset(
+ dns_answer.response.authority, dns_name, _IN, rdtype)
+
+ if not dns_rrset:
+ # Look for NS and other data in Answer section
+ dns_rrset = dns_answer.rrset
+
+ if dns_rrset == ldap_rrset:
+ log_fn('DNS answer matches expectations (attempt %s)',
+ attempt)
+ return
+
+ log_msg = wait_template % (ldap_rrset, dns_answer.response,
+ attempt, period)
+
+ except (dns.resolver.NXDOMAIN,
+ dns.resolver.YXDOMAIN,
+ dns.resolver.NoNameservers,
+ dns.resolver.Timeout) as e:
+ if attempt >= max_attempts:
+ raise
+ else:
+ log_msg = wait_template % (ldap_rrset, type(e), attempt,
+ period)
+
+ log_fn(log_msg)
+ time.sleep(period)
+
+ # Maximum number of attempts was reached
+ else:
+ raise errors.DNSDataMismatch(expected=ldap_rrset, got=dns_rrset)
+
+ def wait_for_modified_attrs(self, entry_attrs, dns_name, dns_domain):
+ '''Wait until DNS resolver returns up-to-date answer for given entry
+ or until the maximum number of attempts is reached.
+
+ :param entry_attrs:
+ None if the entry was deleted from LDAP or
+ LDAPEntry instance containing at least all modified attributes.
+ :param dns_name: FQDN
+ :type dns_name: dns.name.Name
+ :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+ '''
+
+ # represent data in LDAP as dictionary rdtype => rrset
+ ldap_rrsets = self._entry2rrsets(entry_attrs, dns_name, dns_domain)
+ nxdomain = ldap_rrsets is None
+ if nxdomain:
+ # name should not exist => ask for A record and check result
+ ldap_rrsets = {dns.rdatatype.from_text('A'): None}
+
+ for rdtype, ldap_rrset in ldap_rrsets.iteritems():
+ try:
+ self.wait_for_modified_attr(ldap_rrset, rdtype, dns_name)
+
+ except dns.resolver.NXDOMAIN as e:
+ if nxdomain:
+ continue
+ else:
+ e = errors.DNSDataMismatch(expected=ldap_rrset,
+ got="NXDOMAIN")
+ self.log.error(e)
+ raise e
+
+ except dns.resolver.NoNameservers as e:
+ # Do not raise exception if we have got SERVFAILs.
+ # Maybe the user has created an invalid zone intentionally.
+ self.log.warn('waiting for DNS answer {%s}: got {%s}; '
+ 'ignoring', ldap_rrset, type(e))
+ continue
+
+ except dns.exception.DNSException as e:
+ err_desc = str(type(e))
+ err_str = str(e)
+ if err_str:
+ err_desc += ": %s" % err_str
+ e = errors.DNSDataMismatch(expected=ldap_rrset, got=err_desc)
+ self.log.error(e)
+ raise e
+
+ def wait_for_modified_entries(self, entries):
+ '''Call wait_for_modified_attrs for all entries in given dict.
+
+ :param entries:
+ Dict {(dns_domain, dns_name): entry_for_wait_for_modified_attrs}
+ '''
+ for entry_name, entry in entries.iteritems():
+ dns_domain = dns.name.from_text(entry_name[0])
+ dns_name = dns.name.from_text(entry_name[1], origin=dns_domain)
+ self.wait_for_modified_attrs(entry, dns_name, dns_domain)
+
api.register(dnsrecord)
@@ -2559,6 +2738,10 @@ class dnsrecord_add(LDAPCreate):
entry_attrs[attr] = list(set(old_entry.get(attr, []) + vals))
self.obj.check_record_type_collisions(keys, old_entry, entry_attrs)
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
+
return dn
def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
@@ -2586,6 +2769,8 @@ class dnsrecord_add(LDAPCreate):
self.obj.postprocess_record(entry_attrs, **options)
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
return dn
api.register(dnsrecord_add)
@@ -2661,6 +2846,10 @@ class dnsrecord_mod(LDAPUpdate):
entry_attrs[attr] = list(set(old_entry[attr] + new_dnsvalue))
self.obj.check_record_type_collisions(keys, old_entry, entry_attrs)
+
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
return dn
def execute(self, *keys, **options):
@@ -2682,7 +2871,14 @@ class dnsrecord_mod(LDAPUpdate):
break
if del_all:
- return self.obj.methods.delentry(*keys, version=options['version'])
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+ # indicate that entry was deleted
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
+
return result
def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
@@ -2826,7 +3022,10 @@ class dnsrecord_del(LDAPUpdate):
# set del_all flag in context
# when the flag is enabled, the entire DNS record object is deleted
# in a post callback
- setattr(context, 'del_all', del_all)
+ context.del_all = del_all
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
return dn
@@ -2838,13 +3037,23 @@ class dnsrecord_del(LDAPUpdate):
error=_('Zone record \'%s\' cannot be deleted') \
% _dns_zone_record
)
- return self.obj.methods.delentry(*keys, version=options['version'])
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+ if self.api.env['wait_for_dns']:
+ entries = {(keys[0], keys[1]): None}
+ self.obj.wait_for_modified_entries(entries)
+ return result
result = super(dnsrecord_del, self).execute(*keys, **options)
if getattr(context, 'del_all', False) and not \
self.obj.is_pkey_zone_record(*keys):
- return self.obj.methods.delentry(*keys, version=options['version'])
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
return result
def post_callback(self, ldap, dn, entry_attrs, *keys, **options):