freeipa/ipaserver/dns_data_management.py
Julien Rische 673d2b82d0 Generate CNAMEs for TXT+URI location krb records
The IPA location system relies on DNS record priorities in order to give
higher precedence to servers from the same location. For Kerberos, this
is done by redirecting generic SRV records (e.g.
_kerberos._udp.[domain].) to location-aware records (e.g.
_kerberos._udp.[location]._locations.[domain].) using CNAMEs.

This commit applies the same logic for URI records. URI location-aware
record were created, but there were no redirection from generic URI
records. It was causing them to be ignored in practice.

Kerberos URI and TXT records have the same name: "_kerberos". However,
CNAME records cannot coexist with any other record type. To avoid this
conflict, the generic TXT realm record was replaced by location-aware
records, even if the content of these records is the same for all
locations.

Fixes: https://pagure.io/freeipa/issue/9257
Signed-off-by: Julien Rische <jrische@redhat.com>
Reviewed-By: Rob Crittenden <rcritten@redhat.com>
2022-11-23 20:00:17 +01:00

576 lines
19 KiB
Python

#
# 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.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') + 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:
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
)
)
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