# # Copyright (C) 2016 FreeIPA Contributors see COPYING for license # from __future__ import absolute_import import logging import six from collections import defaultdict, OrderedDict from dns import ( rdata, rdataclass, rdatatype, zone, ) from time import sleep, time from ipalib import errors from ipalib.constants import IPA_CA_RECORD from ipalib.dns import record_name_format from ipapython.dnsutil import DNSName from ipaserver.install import installutils if six.PY3: unicode=str logger = logging.getLogger(__name__) IPA_DEFAULT_MASTER_SRV_REC = ( # srv record name, port (DNSName('_ldap._tcp'), 389), # Kerberos records are provided for MIT KRB5 < 1.15 and AD (DNSName('_kerberos._tcp'), 88), (DNSName('_kerberos._udp'), 88), (DNSName('_kerberos-master._tcp'), 88), (DNSName('_kerberos-master._udp'), 88), (DNSName('_kpasswd._tcp'), 464), (DNSName('_kpasswd._udp'), 464), ) IPA_DEFAULT_MASTER_URI_REC = ( # URI record name, URI template # MIT KRB5 1.15+ prefers URI records for service discovery # scheme: always krb5srv # flags: empty or 'm' for primary server # transport: 'tcp', 'udp', or 'kkdcp') # residual: 'hostname', 'hostname:port', or 'https://' URL (DNSName('_kerberos'), "krb5srv:m:tcp:{hostname}"), (DNSName('_kerberos'), "krb5srv:m:udp:{hostname}"), (DNSName('_kpasswd'), "krb5srv:m:tcp:{hostname}"), (DNSName('_kpasswd'), "krb5srv:m:udp:{hostname}"), ) IPA_DEFAULT_ADTRUST_SRV_REC = ( # srv record name, port (DNSName('_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs'), 389), (DNSName('_ldap._tcp.dc._msdcs'), 389), (DNSName('_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs'), 88), (DNSName('_kerberos._udp.Default-First-Site-Name._sites.dc._msdcs'), 88), (DNSName('_kerberos._tcp.dc._msdcs'), 88), (DNSName('_kerberos._udp.dc._msdcs'), 88), ) IPA_DEFAULT_NTP_SRV_REC = ( # srv record name, port (DNSName("_ntp._udp"), 123), ) IPA_DEFAULT_KRB_TXT_REC = ( (DNSName('_kerberos'), "\"{realm}\""), ) CA_RECORDS_DNS_TIMEOUT = 15 # timeout in seconds class IPADomainIsNotManagedByIPAError(Exception): pass class IPASystemRecords: # fixme do it configurable PRIORITY_HIGH = 0 PRIORITY_LOW = 50 # FIXME: use TTL from config TTL = 3600 def __init__(self, api_instance, all_servers=False): self.api_instance = api_instance self.domain_abs = DNSName(self.api_instance.env.domain).make_absolute() self.servers_data = OrderedDict() self.__init_data(all_servers=all_servers) def reload_data(self): """ After any change made to IPA servers, this method must be called to update data in the object, otherwise invalid records may be created/updated """ self.__init_data() def __get_server_attrs(self, server_result): weight = int(server_result.get('ipaserviceweight', ['100'])[0]) location = server_result.get('ipalocation_location', [None])[0] roles = set(server_result.get('enabled_role_servrole', ())) return weight, location, roles def __get_location_suffix(self, location): return location + DNSName('_locations') + self.domain_abs def __init_data(self, all_servers=False): self.servers_data.clear() kwargs = dict(no_members=False) if not all_servers: # only active, fully installed masters] kwargs["servrole"] = "IPA master" servers = self.api_instance.Command.server_find(**kwargs) for s in servers['result']: weight, location, roles = self.__get_server_attrs(s) self.servers_data[s['cn'][0]] = { 'weight': weight, 'location': location, 'roles': roles, } def __add_srv_records( self, zone_obj, hostname, rname_port_map, weight=100, priority=0, location=None ): assert isinstance(hostname, DNSName) assert isinstance(priority, int) assert isinstance(weight, int) if location: suffix = self.__get_location_suffix(location) else: suffix = self.domain_abs for name, port in rname_port_map: rd = rdata.from_text( rdataclass.IN, rdatatype.SRV, '{0} {1} {2} {3}'.format( priority, weight, port, hostname.make_absolute() ) ) r_name = name.derelativize(suffix) rdataset = zone_obj.get_rdataset( r_name, rdatatype.SRV, create=True) rdataset.add(rd, ttl=self.TTL) def __add_uri_records( self, zone_obj, hostname, rname_uri_map, weight=100, priority=0, location=None ): assert isinstance(hostname, DNSName) assert isinstance(priority, int) assert isinstance(weight, int) if location: suffix = self.__get_location_suffix(location) else: suffix = self.domain_abs for name, uri_template in rname_uri_map: uri = uri_template.format(hostname=hostname.make_absolute()) rd = rdata.from_text( rdataclass.IN, rdatatype.URI, '{0} {1} {2}'.format( priority, weight, uri ) ) r_name = name.derelativize(suffix) rdataset = zone_obj.get_rdataset( r_name, rdatatype.URI, create=True) rdataset.add(rd, ttl=self.TTL) def __add_ca_records_from_hostname(self, zone_obj, hostname): assert isinstance(hostname, DNSName) and hostname.is_absolute() r_name = DNSName(IPA_CA_RECORD) + self.domain_abs rrsets = None end_time = time() + CA_RECORDS_DNS_TIMEOUT while True: try: # function logs errors rrsets = installutils.resolve_rrsets_nss(hostname) except OSError: # also retry on EAI_AGAIN, EAI_FAIL pass if rrsets: break if time() >= end_time: break sleep(3) if not rrsets: logger.error('unable to resolve host name %s to IP address, ' 'ipa-ca DNS record will be incomplete', hostname) return for rrset in rrsets: for rd in rrset: logger.debug("Adding CA IP %s for %s", rd.to_text(), hostname) rdataset = zone_obj.get_rdataset( r_name, rd.rdtype, create=True) rdataset.add(rd, ttl=self.TTL) def __add_kerberos_txt_rec(self, zone_obj, location=None): # FIXME: with external DNS, this should generate records for all # realmdomains if location: suffix = self.__get_location_suffix(location) else: suffix = self.domain_abs r_name = DNSName('_kerberos') + suffix rd = rdata.from_text(rdataclass.IN, rdatatype.TXT, self.api_instance.env.realm) rdataset = zone_obj.get_rdataset( r_name, rdatatype.TXT, create=True ) rdataset.add(rd, ttl=self.TTL) def _add_base_dns_records_for_server( self, zone_obj, hostname, roles=None, include_master_role=True, include_kerberos_realm=True, ): server = self.servers_data[hostname] if roles: eff_roles = server['roles'] & set(roles) else: eff_roles = server['roles'] hostname_abs = DNSName(hostname).make_absolute() if include_kerberos_realm: self.__add_kerberos_txt_rec(zone_obj, location=None) # get master records if include_master_role: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_MASTER_SRV_REC, weight=server['weight'] ) self.__add_uri_records( zone_obj, hostname_abs, IPA_DEFAULT_MASTER_URI_REC, weight=server['weight'] ) if 'CA server' in eff_roles: self.__add_ca_records_from_hostname(zone_obj, hostname_abs) if 'AD trust controller' in eff_roles: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_ADTRUST_SRV_REC, weight=server['weight'] ) if 'NTP server' in eff_roles: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_NTP_SRV_REC, weight=server['weight'] ) def _get_location_dns_records_for_server( self, zone_obj, hostname, locations, roles=None, include_master_role=True, include_kerberos_realm=True): server = self.servers_data[hostname] if roles: eff_roles = server['roles'] & roles else: eff_roles = server['roles'] hostname_abs = DNSName(hostname).make_absolute() # generate locations specific records for location in locations: if location == self.servers_data[hostname]['location']: priority = self.PRIORITY_HIGH else: priority = self.PRIORITY_LOW if include_kerberos_realm: self.__add_kerberos_txt_rec(zone_obj, location) if include_master_role: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_MASTER_SRV_REC, weight=server['weight'], priority=priority, location=location ) self.__add_uri_records( zone_obj, hostname_abs, IPA_DEFAULT_MASTER_URI_REC, weight=server['weight'], priority=priority, location=location ) if 'AD trust controller' in eff_roles: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_ADTRUST_SRV_REC, weight=server['weight'], priority=priority, location=location ) if 'NTP server' in eff_roles: self.__add_srv_records( zone_obj, hostname_abs, IPA_DEFAULT_NTP_SRV_REC, weight=server['weight'], priority=priority, location=location ) return zone_obj def __prepare_records_update_dict(self, node): update_dict = defaultdict(list) for rdataset in node: for rdata in rdataset: option_name = (record_name_format % rdatatype.to_text( rdata.rdtype).lower()) update_dict[option_name].append(unicode(rdata.to_text())) return update_dict def __update_dns_records( self, record_name, nodes, set_cname_template=True ): update_dict = self.__prepare_records_update_dict(nodes) cname_template = { 'addattr': ['objectclass=idnsTemplateObject'], 'setattr': [ r'idnsTemplateAttribute;cnamerecord=%s' r'.\{substitutionvariable_ipalocation\}._locations' % record_name.relativize(self.domain_abs) ] } try: if set_cname_template: # only srv records should have configured cname templates update_dict.update(cname_template) self.api_instance.Command.dnsrecord_mod( self.domain_abs, record_name, **update_dict ) except errors.NotFound: # because internal API magic, addattr and setattr doesn't work with # dnsrecord-add well, use dnsrecord-mod instead later update_dict.pop('addattr', None) update_dict.pop('setattr', None) self.api_instance.Command.dnsrecord_add( self.domain_abs, record_name, **update_dict) if set_cname_template: try: self.api_instance.Command.dnsrecord_mod( self.domain_abs, record_name, **cname_template) except errors.EmptyModlist: pass except errors.EmptyModlist: pass def get_base_records( self, servers=None, roles=None, include_master_role=True, include_kerberos_realm=True ): """ Generate IPA service records for specific servers and roles :param servers: list of server which will be used in records, if None all IPA servers will be used :param roles: roles for which DNS records will be generated, if None all roles will be used :param include_master_role: generate records required by IPA master role :return: dns.zone.Zone object that contains base DNS records """ zone_obj = zone.Zone(self.domain_abs, relativize=False) if servers is None: servers = list(self.servers_data) for server in servers: self._add_base_dns_records_for_server(zone_obj, server, roles=roles, include_master_role=include_master_role, include_kerberos_realm=include_kerberos_realm ) return zone_obj def get_locations_records( self, servers=None, roles=None, include_master_role=True, include_kerberos_realm=True ): """ Generate IPA location records for specific servers and roles. :param servers: list of server which will be used in records, if None all IPA servers will be used :param roles: roles for which DNS records will be generated, if None all roles will be used :param include_master_role: generate records required by IPA master role :return: dns.zone.Zone object that contains location DNS records """ zone_obj = zone.Zone(self.domain_abs, relativize=False) if servers is None: servers = list(self.servers_data) locations_result = self.api_instance.Command.location_find()['result'] locations = [l['idnsname'][0] for l in locations_result] for server in servers: self._get_location_dns_records_for_server( zone_obj, server, locations, roles=roles, include_master_role=include_master_role, include_kerberos_realm=include_kerberos_realm) return zone_obj def update_base_records(self): """ Update base DNS records for IPA services :return: [(record_name, node), ...], [(record_name, node, error), ...] where the first list contains successfully updated records, and the second list contains failed updates with particular exceptions """ fail = [] success = [] names_requiring_cname_templates = set( rec[0].derelativize(self.domain_abs) for rec in ( IPA_DEFAULT_MASTER_SRV_REC + IPA_DEFAULT_MASTER_URI_REC + IPA_DEFAULT_KRB_TXT_REC + IPA_DEFAULT_ADTRUST_SRV_REC + IPA_DEFAULT_NTP_SRV_REC ) ) # Remove the ipa-ca record(s). They will be reconstructed in # get_base_records(). r_name = DNSName(IPA_CA_RECORD) + self.domain_abs try: self.api_instance.Command.dnsrecord_del( self.domain_abs, r_name, del_all=True) except errors.NotFound: pass base_zone = self.get_base_records() for record_name, node in base_zone.items(): set_cname_template = record_name in names_requiring_cname_templates try: self.__update_dns_records( record_name, node, set_cname_template) except errors.PublicError as e: fail.append((record_name, node, e)) else: success.append((record_name, node)) return success, fail def update_locations_records(self): """ Update locations DNS records for IPA services :return: [(record_name, node), ...], [(record_name, node, error), ...] where the first list contains successfully updated records, and the second list contains failed updates with particular exceptions """ fail = [] success = [] location_zone = self.get_locations_records() for record_name, nodes in location_zone.items(): try: self.__update_dns_records( record_name, nodes, set_cname_template=False) except errors.PublicError as e: fail.append((record_name, nodes, e)) else: success.append((record_name, nodes)) return success, fail def update_dns_records(self): """ Update all IPA DNS records :return: (sucessfully_updated_base_records, failed_base_records, sucessfully_updated_locations_records, failed_locations_records) For format see update_base_records or update_locations_method :raise IPADomainIsNotManagedByIPAError: if IPA domain is not managed by IPA DNS """ try: self.api_instance.Command.dnszone_show(self.domain_abs) except errors.NotFound: raise IPADomainIsNotManagedByIPAError() return ( self.update_base_records(), self.update_locations_records() ) def remove_location_records(self, location): """ Remove all location records :param location: DNSName object :return: list of successfuly removed record names, list of record names that cannot be removed and returned exception in tuples [rname1, ...], [(rname2, exc), ...] """ success = [] failed = [] location = DNSName(location) loc_records = [] for records in ( IPA_DEFAULT_MASTER_SRV_REC, IPA_DEFAULT_ADTRUST_SRV_REC, IPA_DEFAULT_NTP_SRV_REC ): for name, _port in records: loc_records.append( name + self.__get_location_suffix(location)) for rname in loc_records: try: self.api_instance.Command.dnsrecord_del( self.domain_abs, rname, del_all=True) except errors.NotFound: pass except errors.PublicError as e: failed.append((rname, e)) else: success.append(rname) return success, failed @classmethod def records_list_from_node(cls, name, node): records = [] for rdataset in node: for rd in rdataset: records.append( '{name} {ttl} {rdclass} {rdtype} {rdata}'.format( name=name.ToASCII(), ttl=rdataset.ttl, rdclass=rdataclass.to_text(rd.rdclass), rdtype=rdatatype.to_text(rd.rdtype), rdata=rd.to_text() ) ) return records @classmethod def records_list_from_zone(cls, zone_obj, sort=True): records = [] for name, node in zone_obj.items(): records.extend(IPASystemRecords.records_list_from_node(name, node)) if sort: records.sort() return records