blob: 3b73379f17b8f81ed88d567300224a03171f1ede [file] [log] [blame]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
__all__ = [
'Route53DNSDriver'
]
import base64
import hmac
import datetime
import uuid
import copy
from libcloud.utils.py3 import httplib
from hashlib import sha1
from libcloud.utils.py3 import ET
from libcloud.utils.py3 import b, urlencode
from libcloud.utils.xml import findtext, findall, fixxpath
from libcloud.dns.types import Provider, RecordType
from libcloud.dns.types import ZoneDoesNotExistError, RecordDoesNotExistError
from libcloud.dns.base import DNSDriver, Zone, Record
from libcloud.common.types import LibcloudError
from libcloud.common.aws import AWSGenericResponse, AWSTokenConnection
from libcloud.common.base import ConnectionUserAndKey
API_VERSION = '2012-02-29'
API_HOST = 'route53.amazonaws.com'
API_ROOT = '/%s/' % (API_VERSION)
NAMESPACE = 'https://%s/doc%s' % (API_HOST, API_ROOT)
class InvalidChangeBatch(LibcloudError):
pass
class Route53DNSResponse(AWSGenericResponse):
"""
Amazon Route53 response class.
"""
namespace = NAMESPACE
xpath = 'Error'
exceptions = {
'NoSuchHostedZone': ZoneDoesNotExistError,
'InvalidChangeBatch': InvalidChangeBatch,
}
class BaseRoute53Connection(ConnectionUserAndKey):
host = API_HOST
responseCls = Route53DNSResponse
def pre_connect_hook(self, params, headers):
time_string = datetime.datetime.utcnow() \
.strftime('%a, %d %b %Y %H:%M:%S GMT')
headers['Date'] = time_string
tmp = []
signature = self._get_aws_auth_b64(self.key, time_string)
auth = {'AWSAccessKeyId': self.user_id, 'Signature': signature,
'Algorithm': 'HmacSHA1'}
for k, v in auth.items():
tmp.append('%s=%s' % (k, v))
headers['X-Amzn-Authorization'] = 'AWS3-HTTPS ' + ','.join(tmp)
return params, headers
def _get_aws_auth_b64(self, secret_key, time_string):
b64_hmac = base64.b64encode(
hmac.new(b(secret_key), b(time_string), digestmod=sha1).digest()
)
return b64_hmac.decode('utf-8')
class Route53Connection(AWSTokenConnection, BaseRoute53Connection):
pass
class Route53DNSDriver(DNSDriver):
type = Provider.ROUTE53
name = 'Route53 DNS'
website = 'http://aws.amazon.com/route53/'
connectionCls = Route53Connection
RECORD_TYPE_MAP = {
RecordType.A: 'A',
RecordType.AAAA: 'AAAA',
RecordType.CNAME: 'CNAME',
RecordType.MX: 'MX',
RecordType.NS: 'NS',
RecordType.PTR: 'PTR',
RecordType.SOA: 'SOA',
RecordType.SPF: 'SPF',
RecordType.SRV: 'SRV',
RecordType.TXT: 'TXT',
}
def __init__(self, *args, **kwargs):
self.token = kwargs.pop('token', None)
super(Route53DNSDriver, self).__init__(*args, **kwargs)
def iterate_zones(self):
return self._get_more('zones')
def iterate_records(self, zone):
return self._get_more('records', zone=zone)
def get_zone(self, zone_id):
self.connection.set_context({'zone_id': zone_id})
uri = API_ROOT + 'hostedzone/' + zone_id
data = self.connection.request(uri).object
elem = findall(element=data, xpath='HostedZone',
namespace=NAMESPACE)[0]
return self._to_zone(elem)
def get_record(self, zone_id, record_id):
zone = self.get_zone(zone_id=zone_id)
record_type, name = record_id.split(':', 1)
if name:
full_name = ".".join((name, zone.domain))
else:
full_name = zone.domain
self.connection.set_context({'zone_id': zone_id})
params = urlencode({
'name': full_name,
'type': record_type,
'maxitems': '1'
})
uri = API_ROOT + 'hostedzone/' + zone_id + '/rrset?' + params
data = self.connection.request(uri).object
record = self._to_records(data=data, zone=zone)[0]
# A cute aspect of the /rrset filters is that they are more pagination
# hints than filters!!
# So will return a result even if its not what you asked for.
record_type_num = self._string_to_record_type(record_type)
if record.name != name or record.type != record_type_num:
raise RecordDoesNotExistError(value='', driver=self,
record_id=record_id)
return record
def create_zone(self, domain, type='master', ttl=None, extra=None):
zone = ET.Element('CreateHostedZoneRequest', {'xmlns': NAMESPACE})
ET.SubElement(zone, 'Name').text = domain
ET.SubElement(zone, 'CallerReference').text = str(uuid.uuid4())
if extra and 'Comment' in extra:
hzg = ET.SubElement(zone, 'HostedZoneConfig')
ET.SubElement(hzg, 'Comment').text = extra['Comment']
uri = API_ROOT + 'hostedzone'
data = ET.tostring(zone)
rsp = self.connection.request(uri, method='POST', data=data).object
elem = findall(element=rsp, xpath='HostedZone', namespace=NAMESPACE)[0]
return self._to_zone(elem=elem)
def delete_zone(self, zone, ex_delete_records=False):
self.connection.set_context({'zone_id': zone.id})
if ex_delete_records:
self.ex_delete_all_records(zone=zone)
uri = API_ROOT + 'hostedzone/%s' % (zone.id)
response = self.connection.request(uri, method='DELETE')
return response.status in [httplib.OK]
def create_record(self, name, zone, type, data, extra=None):
if type in (RecordType.TXT, RecordType.SPF):
data = self._quote_data(data)
extra = extra or {}
batch = [('CREATE', name, type, data, extra)]
self._post_changeset(zone, batch)
id = ':'.join((self.RECORD_TYPE_MAP[type], name))
return Record(id=id, name=name, type=type, data=data, zone=zone,
driver=self, ttl=extra.get('ttl', None), extra=extra)
def update_record(self, record, name=None, type=None, data=None,
extra=None):
name = name or record.name
type = type or record.type
extra = extra or record.extra
if not extra:
extra = record.extra
# Multiple value records need to be handled specially - we need to
# pass values for other records as well
multiple_value_record = record.extra.get('_multi_value', False)
other_records = record.extra.get('_other_records', [])
if multiple_value_record and other_records:
self._update_multi_value_record(record=record, name=name,
type=type, data=data,
extra=extra)
else:
self._update_single_value_record(record=record, name=name,
type=type, data=data,
extra=extra)
id = ':'.join((self.RECORD_TYPE_MAP[type], name))
return Record(id=id, name=name, type=type, data=data, zone=record.zone,
driver=self, ttl=extra.get('ttl', None), extra=extra)
def delete_record(self, record):
try:
r = record
batch = [('DELETE', r.name, r.type, r.data, r.extra)]
self._post_changeset(record.zone, batch)
except InvalidChangeBatch:
raise RecordDoesNotExistError(value='', driver=self,
record_id=r.id)
return True
def ex_create_multi_value_record(self, name, zone, type, data, extra=None):
"""
Create a record with multiple values with a single call.
:return: A list of created records.
:rtype: ``list`` of :class:`libcloud.dns.base.Record`
"""
extra = extra or {}
attrs = {'xmlns': NAMESPACE}
changeset = ET.Element('ChangeResourceRecordSetsRequest', attrs)
batch = ET.SubElement(changeset, 'ChangeBatch')
changes = ET.SubElement(batch, 'Changes')
change = ET.SubElement(changes, 'Change')
ET.SubElement(change, 'Action').text = 'CREATE'
rrs = ET.SubElement(change, 'ResourceRecordSet')
ET.SubElement(rrs, 'Name').text = name + '.' + zone.domain
ET.SubElement(rrs, 'Type').text = self.RECORD_TYPE_MAP[type]
ET.SubElement(rrs, 'TTL').text = str(extra.get('ttl', '0'))
rrecs = ET.SubElement(rrs, 'ResourceRecords')
# Value is provided as a multi line string
values = [value.strip() for value in data.split('\n') if
value.strip()]
for value in values:
rrec = ET.SubElement(rrecs, 'ResourceRecord')
ET.SubElement(rrec, 'Value').text = value
uri = API_ROOT + 'hostedzone/' + zone.id + '/rrset'
data = ET.tostring(changeset)
self.connection.set_context({'zone_id': zone.id})
self.connection.request(uri, method='POST', data=data)
id = ':'.join((self.RECORD_TYPE_MAP[type], name))
records = []
for value in values:
record = Record(id=id, name=name, type=type, data=value, zone=zone,
driver=self, ttl=extra.get('ttl', None),
extra=extra)
records.append(record)
return records
def ex_delete_all_records(self, zone):
"""
Remove all the records for the provided zone.
:param zone: Zone to delete records for.
:type zone: :class:`Zone`
"""
deletions = []
for r in zone.list_records():
if r.type in (RecordType.NS, RecordType.SOA):
continue
deletions.append(('DELETE', r.name, r.type, r.data, r.extra))
if deletions:
self._post_changeset(zone, deletions)
def _update_single_value_record(self, record, name=None, type=None,
data=None, extra=None):
batch = [
('DELETE', record.name, record.type, record.data, record.extra),
('CREATE', name, type, data, extra)
]
return self._post_changeset(record.zone, batch)
def _update_multi_value_record(self, record, name=None, type=None,
data=None, extra=None):
other_records = record.extra.get('_other_records', [])
attrs = {'xmlns': NAMESPACE}
changeset = ET.Element('ChangeResourceRecordSetsRequest', attrs)
batch = ET.SubElement(changeset, 'ChangeBatch')
changes = ET.SubElement(batch, 'Changes')
# Delete existing records
change = ET.SubElement(changes, 'Change')
ET.SubElement(change, 'Action').text = 'DELETE'
rrs = ET.SubElement(change, 'ResourceRecordSet')
if record.name:
record_name = record.name + '.' + record.zone.domain
else:
record_name = record.zone.domain
ET.SubElement(rrs, 'Name').text = record_name
ET.SubElement(rrs, 'Type').text = self.RECORD_TYPE_MAP[record.type]
ET.SubElement(rrs, 'TTL').text = str(record.extra.get('ttl', '0'))
rrecs = ET.SubElement(rrs, 'ResourceRecords')
rrec = ET.SubElement(rrecs, 'ResourceRecord')
ET.SubElement(rrec, 'Value').text = record.data
for other_record in other_records:
rrec = ET.SubElement(rrecs, 'ResourceRecord')
ET.SubElement(rrec, 'Value').text = other_record['data']
# Re-create new (updated) records. Since we are updating a multi value
# record, only a single record is updated and others are left as is.
change = ET.SubElement(changes, 'Change')
ET.SubElement(change, 'Action').text = 'CREATE'
rrs = ET.SubElement(change, 'ResourceRecordSet')
if name:
record_name = name + '.' + record.zone.domain
else:
record_name = record.zone.domain
ET.SubElement(rrs, 'Name').text = record_name
ET.SubElement(rrs, 'Type').text = self.RECORD_TYPE_MAP[type]
ET.SubElement(rrs, 'TTL').text = str(extra.get('ttl', '0'))
rrecs = ET.SubElement(rrs, 'ResourceRecords')
rrec = ET.SubElement(rrecs, 'ResourceRecord')
ET.SubElement(rrec, 'Value').text = data
for other_record in other_records:
rrec = ET.SubElement(rrecs, 'ResourceRecord')
ET.SubElement(rrec, 'Value').text = other_record['data']
uri = API_ROOT + 'hostedzone/' + record.zone.id + '/rrset'
data = ET.tostring(changeset)
self.connection.set_context({'zone_id': record.zone.id})
response = self.connection.request(uri, method='POST', data=data)
return response.status == httplib.OK
def _post_changeset(self, zone, changes_list):
attrs = {'xmlns': NAMESPACE}
changeset = ET.Element('ChangeResourceRecordSetsRequest', attrs)
batch = ET.SubElement(changeset, 'ChangeBatch')
changes = ET.SubElement(batch, 'Changes')
for action, name, type_, data, extra in changes_list:
change = ET.SubElement(changes, 'Change')
ET.SubElement(change, 'Action').text = action
rrs = ET.SubElement(change, 'ResourceRecordSet')
if name:
record_name = name + '.' + zone.domain
else:
record_name = zone.domain
ET.SubElement(rrs, 'Name').text = record_name
ET.SubElement(rrs, 'Type').text = self.RECORD_TYPE_MAP[type_]
ET.SubElement(rrs, 'TTL').text = str(extra.get('ttl', '0'))
rrecs = ET.SubElement(rrs, 'ResourceRecords')
rrec = ET.SubElement(rrecs, 'ResourceRecord')
if 'priority' in extra:
data = '%s %s' % (extra['priority'], data)
ET.SubElement(rrec, 'Value').text = data
uri = API_ROOT + 'hostedzone/' + zone.id + '/rrset'
data = ET.tostring(changeset)
self.connection.set_context({'zone_id': zone.id})
response = self.connection.request(uri, method='POST', data=data)
return response.status == httplib.OK
def _to_zones(self, data):
zones = []
for element in data.findall(fixxpath(xpath='HostedZones/HostedZone',
namespace=NAMESPACE)):
zones.append(self._to_zone(element))
return zones
def _to_zone(self, elem):
name = findtext(element=elem, xpath='Name', namespace=NAMESPACE)
id = findtext(element=elem, xpath='Id',
namespace=NAMESPACE).replace('/hostedzone/', '')
comment = findtext(element=elem, xpath='Config/Comment',
namespace=NAMESPACE)
resource_record_count = int(findtext(element=elem,
xpath='ResourceRecordSetCount',
namespace=NAMESPACE))
extra = {'Comment': comment, 'ResourceRecordSetCount':
resource_record_count}
zone = Zone(id=id, domain=name, type='master', ttl=0, driver=self,
extra=extra)
return zone
def _to_records(self, data, zone):
records = []
elems = data.findall(
fixxpath(xpath='ResourceRecordSets/ResourceRecordSet',
namespace=NAMESPACE))
for elem in elems:
record_set = elem.findall(fixxpath(
xpath='ResourceRecords/ResourceRecord',
namespace=NAMESPACE))
record_count = len(record_set)
multiple_value_record = (record_count > 1)
record_set_records = []
for index, record in enumerate(record_set):
# Need to special handling for records with multiple values for
# update to work correctly
record = self._to_record(elem=elem, zone=zone, index=index)
record.extra['_multi_value'] = multiple_value_record
if multiple_value_record:
record.extra['_other_records'] = []
record_set_records.append(record)
# Store reference to other records so update works correctly
if multiple_value_record:
for index in range(0, len(record_set_records)):
record = record_set_records[index]
for other_index, other_record in \
enumerate(record_set_records):
if index == other_index:
# Skip current record
continue
extra = copy.deepcopy(other_record.extra)
extra.pop('_multi_value')
extra.pop('_other_records')
item = {'name': other_record.name,
'data': other_record.data,
'type': other_record.type,
'extra': extra}
record.extra['_other_records'].append(item)
records.extend(record_set_records)
return records
def _to_record(self, elem, zone, index=0):
name = findtext(element=elem, xpath='Name',
namespace=NAMESPACE)
name = name[:-len(zone.domain) - 1]
type = self._string_to_record_type(findtext(element=elem, xpath='Type',
namespace=NAMESPACE))
ttl = findtext(element=elem, xpath='TTL', namespace=NAMESPACE)
if ttl is not None:
ttl = int(ttl)
value_elem = elem.findall(
fixxpath(xpath='ResourceRecords/ResourceRecord',
namespace=NAMESPACE))[index]
data = findtext(element=(value_elem), xpath='Value',
namespace=NAMESPACE)
extra = {'ttl': ttl}
if type == 'MX':
split = data.split()
priority, data = split
extra['priority'] = int(priority)
elif type == 'SRV':
split = data.split()
priority, weight, port, data = split
extra['priority'] = int(priority)
extra['weight'] = int(weight)
extra['port'] = int(port)
id = ':'.join((self.RECORD_TYPE_MAP[type], name))
record = Record(id=id, name=name, type=type, data=data, zone=zone,
driver=self, ttl=extra.get('ttl', None), extra=extra)
return record
def _get_more(self, rtype, **kwargs):
exhausted = False
last_key = None
while not exhausted:
items, last_key, exhausted = self._get_data(rtype, last_key,
**kwargs)
for item in items:
yield item
def _get_data(self, rtype, last_key, **kwargs):
params = {}
if last_key:
params['name'] = last_key
path = API_ROOT + 'hostedzone'
if rtype == 'zones':
response = self.connection.request(path, params=params)
transform_func = self._to_zones
elif rtype == 'records':
zone = kwargs['zone']
path += '/%s/rrset' % (zone.id)
self.connection.set_context({'zone_id': zone.id})
response = self.connection.request(path, params=params)
transform_func = self._to_records
if response.status == httplib.OK:
is_truncated = findtext(element=response.object,
xpath='IsTruncated',
namespace=NAMESPACE)
exhausted = is_truncated != 'true'
last_key = findtext(element=response.object,
xpath='NextRecordName',
namespace=NAMESPACE)
items = transform_func(data=response.object, **kwargs)
return items, last_key, exhausted
else:
return [], None, True
def _ex_connection_class_kwargs(self):
kwargs = super(Route53DNSDriver, self)._ex_connection_class_kwargs()
kwargs['token'] = self.token
return kwargs
def _quote_data(self, data):
if data[0] == '"' and data[-1] == '"':
return data
return '"{0}"'.format(data.replace('"', '\"'))