# Copyright 2016 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import cachetools import os import time import uuid import six from six.moves import http_client try: from gssapi.exceptions import GSSError from ipalib import api from ipalib import errors ipalib_imported = True except ImportError: # ipalib/ipapython are not available in PyPy yet, don't make it # a showstopper for the tests. ipalib_imported = False if ipalib_imported: try: from ipapython.ipautil import kinit_keytab except ImportError: # The import moved in freeIPA 4.5.0 try: from ipalib.install.kinit import kinit_keytab except ImportError: ipalib_imported = False from novajoin import exception from novajoin.util import get_domain from oslo_config import cfg from oslo_log import log as logging from six.moves.configparser import SafeConfigParser CONF = cfg.CONF LOG = logging.getLogger(__name__) class IPANovaJoinBase(object): def __init__(self): if not ipalib_imported: return self.ntries = CONF.connect_retries self.initial_backoff = CONF.connect_backoff self.ccache = "MEMORY:" + str(uuid.uuid4()) os.environ['KRB5CCNAME'] = self.ccache if self._ipa_client_configured() and not api.isdone('finalize'): (hostname, realm) = self.get_host_and_realm() kinit_keytab(str('nova/%s@%s' % (hostname, realm)), CONF.keytab, self.ccache) api.bootstrap(context='novajoin') api.finalize() self.batch_args = list() self.backoff = self.initial_backoff def split_principal(self, principal): """Split a principal into its components. Copied from IPA 4.0.0""" service = hostname = realm = None # Break down the principal into its component parts, which may or # may not include the realm. sp = principal.split('/') if len(sp) != 2: raise errors.MalformedServicePrincipal(reason=_('missing service')) service = sp[0] if len(service) == 0: raise errors.MalformedServicePrincipal(reason=_('blank service')) sr = sp[1].split('@') if len(sr) > 2: raise errors.MalformedServicePrincipal( reason=_('unable to determine realm')) hostname = sr[0].lower() if len(sr) == 2: realm = sr[1].upper() # At some point we'll support multiple realms if realm != api.env.realm: raise errors.RealmMismatch() else: realm = api.env.realm # Note that realm may be None. return (service, hostname, realm) def split_hostname(self, hostname): """Split a hostname into its host and domain parts""" parts = hostname.split('.') domain = six.text_type('.'.join(parts[1:]) + '.') return (parts[0], domain) def get_host_and_realm(self): """Return the hostname and IPA realm name. IPA 4.4 introduced the requirement that the schema be fetched when calling finalize(). This is really only used by the ipa command-line tool but for now it is baked in. So we have to get a TGT first but need the hostname and realm. For now directly read the IPA config file which is in INI format and pull those two values out and return as a tuple. """ config = SafeConfigParser() config.read('/etc/ipa/default.conf') hostname = config.get('global', 'host') realm = config.get('global', 'realm') return (hostname, realm) def __backoff(self): LOG.debug("Backing off %s seconds", self.backoff) time.sleep(self.backoff) if self.backoff < 512: self.backoff = self.backoff * 2 def __reset_backoff(self): if self.backoff > self.initial_backoff: LOG.debug("Resetting backoff to %d", self.initial_backoff) self.backoff = self.initial_backoff def __get_connection(self): """Make a connection to IPA or raise an error.""" tries = 0 while (tries <= self.ntries): LOG.debug("Attempt %d of %d", tries, self.ntries) if api.Backend.rpcclient.isconnected(): api.Backend.rpcclient.disconnect() try: api.Backend.rpcclient.connect() # ping to force an actual connection in case there is only one # IPA master api.Command[u'ping']() except (errors.CCacheError, errors.TicketExpired, errors.KerberosError) as e: tries += 1 LOG.debug("kinit again: %s", e) # pylint: disable=no-member try: kinit_keytab(str('nova/%s@%s' % (api.env.host, api.env.realm)), CONF.keytab, self.ccache) except GSSError as e: LOG.debug("kinit failed: %s", e) if self.backoff: self.__backoff() except errors.NetworkError: tries += 1 if self.backoff: self.__backoff() except http_client.ResponseNotReady: # NOTE(xek): This means that the server closed the socket, # so keep-alive ended and we can't use that connection. api.Backend.rpcclient.disconnect() tries += 1 if self.backoff: self.__backoff() else: # successful connection self.__reset_backoff() return LOG.error("Failed to connect to IPA after %d attempts", self.ntries) raise exception.IPAConnectionError(tries=self.ntries) def start_batch_operation(self): """Start a batch operation. IPA method calls will be collected in a batch job and submitted to IPA once all the operations have collected by a call to _flush_batch_operation(). """ self.batch_args = list() def _add_batch_operation(self, command, *args, **kw): """Add an IPA call to the batch operation""" self.batch_args.append({ "method": six.text_type(command), "params": [args, kw], }) def flush_batch_operation(self): """Make an IPA batch call.""" LOG.debug("flush_batch_operation") if not self.batch_args: return None kw = {} LOG.debug(self.batch_args) return self._call_ipa('batch', *self.batch_args, **kw) def _call_ipa(self, command, *args, **kw): """Make an IPA call.""" if not api.Backend.rpcclient.isconnected(): self.__get_connection() if 'version' not in kw: kw['version'] = u'2.146' # IPA v4.2.0 for compatibility while True: try: result = api.Command[command](*args, **kw) LOG.debug(result) return result except (errors.CCacheError, errors.TicketExpired, errors.KerberosError): LOG.debug("Refresh authentication") self.__get_connection() except errors.NetworkError: if self.backoff: self.__backoff() else: raise except http_client.ResponseNotReady: # NOTE(xek): This means that the server closed the socket, # so keep-alive ended and we can't use that connection. api.Backend.rpcclient.disconnect() if self.backoff: self.__backoff() else: raise def _ipa_client_configured(self): """Determine if the machine is an enrolled IPA client. Return boolean indicating whether this machine is enrolled in IPA. This is a rather weak detection method but better than nothing. """ return os.path.exists('/etc/ipa/default.conf') class IPAClient(IPANovaJoinBase): # TODO(jaosorior): Make the cache time and ttl configurable host_cache = cachetools.TTLCache(maxsize=512, ttl=30) service_cache = cachetools.TTLCache(maxsize=512, ttl=30) def add_host(self, hostname, ipaotp, metadata=None, image_metadata=None): """Add a host to IPA. If requested in the metadata, add a host to IPA. The assumption is that hostname is already fully-qualified. Because this is triggered by a metadata request, which can happen multiple times, first we try to update the OTP in the host entry and if that fails due to NotFound the host is added. """ LOG.debug('Adding ' + hostname + ' to IPA.') if not self._ipa_client_configured(): LOG.debug('IPA is not configured') return False # There's no use in doing any operations if ipalib hasn't been # imported. if not ipalib_imported: return True if metadata is None: metadata = {} if image_metadata is None: image_metadata = {} if hostname in self.host_cache: LOG.debug('Host ' + hostname + ' found in cache.') return self.host_cache[hostname] params = [hostname] hostclass = metadata.get('ipa_hostclass', '') location = metadata.get('ipa_host_location', '') osdistro = image_metadata.get('os_distro', '') osver = image_metadata.get('os_version', '') # 'description': 'IPA host for %s' % inst.display_description, hostargs = { 'description': u'IPA host for OpenStack', 'userpassword': six.text_type(ipaotp), 'force': True # we don't have an ip addr yet so # use force to add anyway } if hostclass: hostargs['userclass'] = hostclass if osdistro or osver: hostargs['nsosversion'] = '%s %s' % (osdistro, osver) hostargs['nsosversion'] = hostargs['nsosversion'].strip() if location: hostargs['nshostlocation'] = location modargs = { 'userpassword': six.text_type(ipaotp), } try: self._call_ipa('host_mod', *params, **modargs) self.host_cache[hostname] = six.text_type(ipaotp) except errors.NotFound: try: self._call_ipa('host_add', *params, **hostargs) self.host_cache[hostname] = six.text_type(ipaotp) except errors.DuplicateEntry: # We have no idea what the OTP is for the existing host. return False except (errors.ValidationError, errors.DNSNotARecordError): # Assumes despite these exceptions the host was created # and the OTP was set. self.host_cache[hostname] = six.text_type(ipaotp) except errors.ValidationError: # Updating the OTP on an enrolled-host is not allowed # in IPA and really a no-op. # We don't know the OTP of the host, so we cannot update the cache. return False return self.host_cache.get(hostname, False) def add_subhost(self, hostname): """Add a subhost to IPA. Servers can have multiple network interfaces, and therefore can have multiple aliases. Moreover, they can part of a service using a virtual host (VIP). These aliases are denoted 'subhosts', """ LOG.debug('Adding subhost: ' + hostname) if hostname not in self.host_cache: params = [hostname] hostargs = {'force': True} self._add_batch_operation('host_add', *params, **hostargs) self.host_cache[hostname] = True else: LOG.debug('subhost ' + hostname + ' found in cache.') def delete_subhost(self, hostname, batch=True): """Delete a subhost from IPA. Servers can have multiple network interfaces, and therefore can have multiple aliases. Moreover, they can part of a service using a virtual host (VIP). These aliases are denoted 'subhosts', """ LOG.debug('Deleting subhost: ' + hostname) host_params = [hostname] (hn, domain) = self.split_hostname(hostname) dns_params = [domain, hn] # If there is no DNS entry, this operation fails host_kw = {'updatedns': False, } dns_kw = {'del_all': True, } if batch: if hostname in self.host_cache: del self.host_cache[hostname] self._add_batch_operation('host_del', *host_params, **host_kw) self._add_batch_operation('dnsrecord_del', *dns_params, **dns_kw) else: if hostname in self.host_cache: del self.host_cache[hostname] self._call_ipa('host_del', *host_params, **host_kw) try: self._call_ipa('dnsrecord_del', *dns_params, **dns_kw) except (errors.NotFound, errors.ACIError): # Ignore DNS deletion errors pass def delete_host(self, hostname, metadata=None): """Delete a host from IPA and remove all related DNS entries.""" LOG.debug('Deleting ' + hostname + ' from IPA.') if not self._ipa_client_configured(): LOG.debug('IPA is not configured') return if metadata is None: metadata = {} # TODO(rcrit): lookup instance in nova to get metadata to see if # the host was enrolled. For now assume yes. params = [hostname] kw = { 'updatedns': False, } try: if hostname in self.host_cache: del self.host_cache[hostname] self._call_ipa('host_del', *params, **kw) except (errors.NotFound, errors.ACIError): # Trying to delete a host that doesn't exist will raise an ACIError # to hide whether the entry exists or not pass (hn, domain) = self.split_hostname(hostname) dns_params = [domain, hn] dns_kw = {'del_all': True, } try: self._call_ipa('dnsrecord_del', *dns_params, **dns_kw) except (errors.NotFound, errors.ACIError): # Ignore DNS deletion errors pass def add_service(self, principal): if principal not in self.service_cache: try: (service, hostname, realm) = self.split_principal(principal) except errors.MalformedServicePrincipal as e: LOG.error("Unable to split principal %s: %s", principal, e) raise LOG.debug('Adding service: ' + principal) params = [principal] service_args = {'force': True} self._add_batch_operation('service_add', *params, **service_args) self.service_cache[principal] = [hostname] else: LOG.debug('Service ' + principal + ' found in cache.') def service_add_host(self, service_principal, host): """Add a host to a service. In IPA there is a relationship between a host and the services for that host. The host has the right to manage keytabs and SSL certificates for its own services. There are reasons that a host may want to manage services for another host or service: virtualization, load balancing, etc. In order to do this you mark the host or service as being "managed by" another host. For services in IPA this is done using the service-add-host API. """ if host not in self.service_cache.get(service_principal, []): LOG.debug('Adding principal ' + service_principal + ' to host ' + host) params = [service_principal] service_args = {'host': (host,)} self._add_batch_operation('service_add_host', *params, **service_args) self.service_cache[service_principal] = self.service_cache.get( service_principal, []) + [host] else: LOG.debug('Host ' + host + ' managing ' + service_principal + ' found in cache.') def service_has_hosts(self, service_principal): """Return True if hosts other than parent manages this service""" LOG.debug('Checking if principal ' + service_principal + ' has hosts') params = [service_principal] service_args = {} try: result = self._call_ipa('service_show', *params, **service_args) except errors.NotFound: raise KeyError serviceresult = result['result'] try: (service, hostname, realm) = self.split_principal( service_principal ) except errors.MalformedServicePrincipal as e: LOG.error("Unable to split principal %s: %s", service_principal, e) raise for candidate in serviceresult.get('managedby_host', []): if candidate != hostname: return True return False def host_get_services(self, service_host): """Return list of services this host manages""" LOG.debug('Checking host ' + service_host + ' services') params = [] service_args = {'man_by_host': six.text_type(service_host)} result = self._call_ipa('service_find', *params, **service_args) return [service['krbprincipalname'][0] for service in result['result']] def host_has_services(self, service_host): """Return True if this host manages any services""" return len(self.host_get_services(service_host)) > 0 def find_host(self, hostname): """Return True if this host exists""" LOG.debug('Checking if host ' + hostname + ' exists') params = [] service_args = {'fqdn': six.text_type(hostname)} result = self._call_ipa('host_find', *params, **service_args) return result['count'] > 0 def delete_service(self, principal, batch=True): LOG.debug('Deleting service: ' + principal) params = [principal] service_args = {} if batch: if principal in self.service_cache: del self.service_cache[principal] self._add_batch_operation('service_del', *params, **service_args) else: if principal in self.service_cache: del self.service_cache[principal] return self._call_ipa('service_del', *params, **service_args) def add_ip(self, hostname, floating_ip): """Add a floating IP to a given hostname.""" LOG.debug('In add_ip') if not self._ipa_client_configured(): LOG.debug('IPA is not configured') return params = [six.text_type(get_domain() + '.'), six.text_type(hostname)] kw = {'a_part_ip_address': six.text_type(floating_ip)} try: self._call_ipa('dnsrecord_add', *params, **kw) except (errors.DuplicateEntry, errors.ValidationError): pass def find_record(self, floating_ip): """Find DNS A record for floating IP address""" LOG.debug('looking up host for floating ip' + floating_ip) params = [six.text_type(get_domain() + '.')] service_args = {'arecord': six.text_type(floating_ip)} result = self._call_ipa('dnsrecord_find', *params, **service_args) if result['count'] == 0: return assert(result['count'] == 1) return result['result'][0]['idnsname'][0].to_unicode() def remove_ip(self, floating_ip): """Remove a floating IP from a given hostname.""" LOG.debug('In remove_ip') if not self._ipa_client_configured(): LOG.debug('IPA is not configured') return hostname = self.find_record(floating_ip) if not hostname: LOG.debug('floating IP record not found') return params = [six.text_type(get_domain() + '.'), hostname] service_args = {'arecord': six.text_type(floating_ip)} self._call_ipa('dnsrecord_del', *params, **service_args)