designate/designate/backend/impl_ipa/__init__.py

431 lines
16 KiB
Python

# Copyright 2014 Red Hat, Inc.
#
# Author: Rich Megginson <rmeggins@redhat.com>
#
# 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 pprint
import time
import requests
from oslo.config import cfg
from oslo.serialization import jsonutils as json
from oslo.utils import importutils
from designate import exceptions
from designate.openstack.common import log as logging
from designate.backend import base
from designate.i18n import _LE
LOG = logging.getLogger(__name__)
cfg.CONF.register_group(cfg.OptGroup(
name='backend:ipa', title="Configuration for IPA Backend"
))
IPA_DEFAULT_PORT = 443
OPTS = [
cfg.StrOpt('ipa-host', default='localhost.localdomain',
help='IPA RPC listener host - must be FQDN'),
cfg.IntOpt('ipa-port', default=IPA_DEFAULT_PORT,
help='IPA RPC listener port'),
cfg.StrOpt('ipa-client-keytab', default=None,
help='Kerberos client keytab file'),
cfg.StrOpt('ipa-auth-driver-class',
default='designate.backend.impl_ipa.auth.IPAAuth',
help='Class that implements the authentication '
'driver for IPA'),
cfg.StrOpt('ipa-ca-cert', default=None,
help='CA certificate for use with https to IPA'),
cfg.StrOpt('ipa-base-url', default='/ipa',
help='Base URL for IPA RPC, relative to host[:port]'),
cfg.StrOpt('ipa-json-url',
default='/json',
help='URL for IPA JSON RPC, relative to IPA base URL'),
cfg.IntOpt('ipa-connect-retries', default=1,
help='How many times Designate will attempt to retry '
'the connection to IPA before giving up'),
cfg.BoolOpt('ipa-force-ns-use', default=False,
help='IPA requires that a specified '
'name server or SOA MNAME is resolvable - if this '
'option is set, Designate will force IPA to use a '
'given name server even if it is not resolvable'),
cfg.StrOpt('ipa-version', default='2.65',
help='IPA RPC JSON version')
]
cfg.CONF.register_opts(OPTS, group='backend:ipa')
class IPABaseError(exceptions.Backend):
error_code = 500
error_type = 'unknown_ipa_error'
class IPAAuthError(IPABaseError):
error_type = 'authentication_error'
# map of designate domain parameters to the corresponding
# ipa parameter
# NOTE: ipa manages serial, and does not honor
# increment_serial=False - this means the designate serial
# and the ipa serial will diverge if updates are made
# using increment_serial=False
domain2ipa = {'ttl': 'dnsttl', 'email': 'idnssoarname',
'serial': 'idnssoaserial', 'expire': 'idnssoaexpire',
'minimum': 'idnssoaminimum', 'refresh': 'idnssoarefresh',
'retry': 'idnssoaretry'}
# map of designate record types to ipa
rectype2iparectype = {'A': ('arecord', '%(data)s'),
'AAAA': ('aaaarecord', '%(data)s'),
'MX': ('mxrecord', '%(priority)d %(data)s'),
'CNAME': ('cnamerecord', '%(data)s'),
'TXT': ('txtrecord', '%(data)s'),
'SRV': ('srvrecord', '%(priority)d %(data)s'),
'NS': ('nsrecord', '%(data)s'),
'PTR': ('ptrrecord', '%(data)s'),
'SPF': ('spfrecord', '%(data)s'),
'SSHFP': ('sshfprecord', '%(data)s')}
IPA_INVALID_DATA = 3009
IPA_NOT_FOUND = 4001
IPA_DUPLICATE = 4002
IPA_NO_CHANGES = 4202
class IPAUnknownError(IPABaseError):
pass
class IPACommunicationFailure(IPABaseError):
error_type = 'communication_failure'
pass
class IPAInvalidData(IPABaseError):
error_type = 'invalid_data'
pass
class IPADomainNotFound(IPABaseError):
error_type = 'domain_not_found'
pass
class IPARecordNotFound(IPABaseError):
error_type = 'record_not_found'
pass
class IPADuplicateDomain(IPABaseError):
error_type = 'duplicate_domain'
pass
class IPADuplicateRecord(IPABaseError):
error_type = 'duplicate_record'
pass
ipaerror2exception = {
IPA_INVALID_DATA: {
'dnszone': IPAInvalidData,
'dnsrecord': IPAInvalidData
},
IPA_NOT_FOUND: {
'dnszone': IPADomainNotFound,
'dnsrecord': IPARecordNotFound
},
IPA_DUPLICATE: {
'dnszone': IPADuplicateDomain,
'dnsrecord': IPADuplicateRecord
},
# NOTE: Designate will send updates with all fields
# even if they have not changed value. If none of
# the given values has changed, IPA will return
# this error code - this can be ignored
IPA_NO_CHANGES: {
'dnszone': None,
'dnsrecord': None
}
}
def abs2rel_name(domain, rsetname):
"""convert rsetname from absolute form foo.bar.tld. to the name
relative to the domain. For IPA, if domain is rsetname, then use
"@" as the relative name. If rsetname does not end with a subset
of the domain, the just return the raw rsetname
"""
if rsetname.endswith(domain):
idx = rsetname.rfind(domain)
if idx == 0:
rsetname = "@"
elif idx > 0:
rsetname = rsetname[:idx].rstrip(".")
return rsetname
class IPABackend(base.Backend):
__plugin_name__ = 'ipa'
def start(self):
LOG.debug('IPABackend start')
self.request = requests.Session()
authclassname = cfg.CONF[self.name].ipa_auth_driver_class
authclass = importutils.import_class(authclassname)
self.request.auth = \
authclass(cfg.CONF[self.name].ipa_client_keytab,
cfg.CONF[self.name].ipa_host)
ipa_base_url = cfg.CONF[self.name].ipa_base_url
if ipa_base_url.startswith("http"): # full URL
self.baseurl = ipa_base_url
else: # assume relative to https://host[:port]
self.baseurl = "https://" + cfg.CONF[self.name].ipa_host
ipa_port = cfg.CONF[self.name].ipa_port
if ipa_port != IPA_DEFAULT_PORT:
self.baseurl += ":" + str(ipa_port)
self.baseurl += ipa_base_url
ipa_json_url = cfg.CONF[self.name].ipa_json_url
if ipa_json_url.startswith("http"): # full URL
self.jsonurl = ipa_json_url
else: # assume relative to https://host[:port]
self.jsonurl = self.baseurl + ipa_json_url
xtra_hdrs = {'Content-Type': 'application/json',
'Referer': self.baseurl}
self.request.headers.update(xtra_hdrs)
self.request.verify = cfg.CONF[self.name].ipa_ca_cert
self.ntries = cfg.CONF[self.name].ipa_connect_retries
self.force = cfg.CONF[self.name].ipa_force_ns_use
def create_domain(self, context, domain):
LOG.debug('Create Domain %r' % domain)
ipareq = {'method': 'dnszone_add', 'id': 0}
params = [domain['name']]
servers = self.central_service.find_servers(self.admin_context)
# just use the first one for zone creation - add the others
# later, below - use force because designate assumes the NS
# already exists somewhere, is resolvable, and already has
# an A/AAAA record
args = {'idnssoamname': servers[0]['name']}
if self.force:
args['force'] = True
for dkey, ipakey in domain2ipa.iteritems():
if dkey in domain:
args[ipakey] = domain[dkey]
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
# add NS records for all of the other servers
if len(servers) > 1:
ipareq = {'method': 'dnsrecord_add', 'id': 0}
params = [domain['name'], "@"]
args = {'nsrecord': servers[1:]}
if self.force:
args['force'] = True
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def update_domain(self, context, domain):
LOG.debug('Update Domain %r' % domain)
ipareq = {'method': 'dnszone_mod', 'id': 0}
params = [domain['name']]
args = {}
for dkey, ipakey in domain2ipa.iteritems():
if dkey in domain:
args[ipakey] = domain[dkey]
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def delete_domain(self, context, domain):
LOG.debug('Delete Domain %r' % domain)
ipareq = {'method': 'dnszone_del', 'id': 0}
params = [domain['name']]
args = {}
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def create_recordset(self, context, domain, recordset):
LOG.debug('Discarding create_recordset call, not-applicable')
def update_recordset(self, context, domain, recordset):
LOG.debug('Update RecordSet %r / %r' % (domain, recordset))
# designate allows to update a recordset if there are no
# records in it - we should ignore this case
if not self._recset_has_records(context, recordset):
LOG.debug('No records in %r / %r - skipping' % (domain, recordset))
return
# The only thing IPA allows is to change the ttl, since that is
# stored "per recordset"
if 'ttl' not in recordset:
return
ipareq = {'method': 'dnsrecord_mod', 'id': 0}
dname = domain['name']
rsetname = abs2rel_name(dname, recordset['name'])
params = [domain['name'], rsetname]
args = {'dnsttl': recordset['ttl']}
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def delete_recordset(self, context, domain, recordset):
LOG.debug('Delete RecordSet %r / %r' % (domain, recordset))
# designate allows to delete a recordset if there are no
# records in it - we should ignore this case
if not self._recset_has_records(context, recordset):
LOG.debug('No records in %r / %r - skipping' % (domain, recordset))
return
ipareq = {'method': 'dnsrecord_mod', 'id': 0}
dname = domain['name']
rsetname = abs2rel_name(dname, recordset['name'])
params = [domain['name'], rsetname]
rsettype = rectype2iparectype[recordset['type']][0]
args = {rsettype: None}
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def create_record(self, context, domain, recordset, record):
LOG.debug('Create Record %r / %r / %r' % (domain, recordset, record))
ipareq = {'method': 'dnsrecord_add', 'id': 0}
params, args = self._rec_to_ipa_rec(domain, recordset, [record])
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def update_record(self, context, domain, recordset, record):
LOG.debug('Update Record %r / %r / %r' % (domain, recordset, record))
# for modify operations - IPA does not support a way to change
# a particular field in a given record - e.g. for an MX record
# with several values, IPA stores them like this:
# name: "server1.local."
# data: ["10 mx1.server1.local.", "20 mx2.server1.local."]
# we could do a search of IPA, compare the values in the
# returned array - but that adds an additional round trip
# and is error prone
# instead, we just get all of the current values and send
# them in one big modify
criteria = {'recordset_id': record['recordset_id']}
reclist = self.central_service.find_records(self.admin_context,
criteria)
ipareq = {'method': 'dnsrecord_mod', 'id': 0}
params, args = self._rec_to_ipa_rec(domain, recordset, reclist)
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def delete_record(self, context, domain, recordset, record):
LOG.debug('Delete Record %r / %r / %r' % (domain, recordset, record))
ipareq = {'method': 'dnsrecord_del', 'id': 0}
params, args = self._rec_to_ipa_rec(domain, recordset, [record])
args['del_all'] = 0
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
def ping(self, context):
LOG.debug('Ping')
# NOTE: This call will cause ipa to issue an error, but
# 1) it should not throw an exception
# 2) the response will indicate ipa is running
# 3) the bandwidth usage is minimal
ipareq = {'method': 'dnszone_show', 'id': 0}
params = ['@']
args = {}
ipareq['params'] = [params, args]
retval = {'result': True}
try:
self._call_and_handle_error(ipareq)
except Exception as e:
retval = {'result': False, 'reason': str(e)}
return retval
def _rec_to_ipa_rec(self, domain, recordset, reclist):
dname = domain['name']
rsetname = abs2rel_name(dname, recordset['name'])
params = [dname, rsetname]
rectype = recordset['type']
vals = []
for record in reclist:
vals.append(rectype2iparectype[rectype][1] % record)
args = {rectype2iparectype[rectype][0]: vals}
ttl = recordset.get('ttl') or domain.get('ttl')
if ttl is not None:
args['dnsttl'] = ttl
return params, args
def _ipa_error_to_exception(self, resp, ipareq):
exc = None
if resp['error'] is None:
return exc
errcode = resp['error']['code']
method = ipareq['method']
methtype = method.split('_')[0]
exclass = ipaerror2exception.get(errcode, {}).get(methtype,
IPAUnknownError)
if exclass:
LOG.debug("Error: ipa command [%s] returned error [%s]" %
(pprint.pformat(ipareq), pprint.pformat(resp)))
elif errcode: # not mapped
LOG.debug("Ignoring IPA error code %d: %s" %
(errcode, pprint.pformat(resp)))
return exclass
def _call_and_handle_error(self, ipareq):
if 'version' not in ipareq['params'][1]:
ipareq['params'][1]['version'] = cfg.CONF[self.name].ipa_version
need_reauth = False
while True:
status_code = 200
try:
if need_reauth:
self.request.auth.refresh_auth()
rawresp = self.request.post(self.jsonurl,
data=json.dumps(ipareq))
status_code = rawresp.status_code
except IPAAuthError:
status_code = 401
if status_code == 401:
if self.ntries == 0:
# persistent inability to auth
LOG.error(_LE("Error: could not authenticate to IPA - "
"please check for correct keytab file"))
# reset for next time
self.ntries = cfg.CONF[self.name].ipa_connect_retries
raise IPACommunicationFailure()
else:
LOG.debug("Refresh authentication")
need_reauth = True
self.ntries -= 1
time.sleep(1)
else:
# successful - reset
self.ntries = cfg.CONF[self.name].ipa_connect_retries
break
try:
resp = json.loads(rawresp.text)
except ValueError:
# response was not json - some sort of error response
LOG.debug("Error: unknown error from IPA [%s]" % rawresp.text)
raise IPAUnknownError("unable to process response from IPA")
# raise the appropriate exception, if error
exclass = self._ipa_error_to_exception(resp, ipareq)
if exclass:
# could add additional info/message to exception here
raise exclass()
return resp
def _recset_has_records(self, context, recordset):
"""Return True if the recordset has records, False otherwise"""
criteria = {'recordset_id': recordset['id']}
num = self.central_service.count_records(self.admin_context,
criteria)
return num > 0