559 lines
20 KiB
Python
559 lines
20 KiB
Python
# 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.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, backoff=0):
|
|
try:
|
|
self.ntries = CONF.connect_retries
|
|
except cfg.NoSuchOptError:
|
|
self.ntries = 1
|
|
if not ipalib_imported:
|
|
return
|
|
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 = 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 < 1024:
|
|
self.backoff = self.backoff * 2
|
|
|
|
def __get_connection(self):
|
|
"""Make a connection to IPA or raise an error."""
|
|
tries = 0
|
|
|
|
while (tries <= self.ntries) or (self.backoff > 0):
|
|
if self.backoff == 0:
|
|
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:
|
|
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 tries > 0 and self.backoff:
|
|
self.__backoff()
|
|
tries += 1
|
|
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:
|
|
return
|
|
|
|
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": 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_has_services(self, service_host):
|
|
"""Return True if this host manages any services"""
|
|
LOG.debug('Checking if host ' + service_host + ' has services')
|
|
params = []
|
|
service_args = {'man_by_host': service_host}
|
|
result = self._call_ipa('service_find', *params, **service_args)
|
|
return result['count'] > 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)
|