| # 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 copy |
| import hmac |
| import uuid |
| import base64 |
| import datetime |
| from hashlib import sha1 |
| |
| from libcloud.dns.base import Zone, Record, DNSDriver |
| from libcloud.dns.types import Provider, RecordType, ZoneDoesNotExistError, RecordDoesNotExistError |
| from libcloud.utils.py3 import ET, b, httplib, urlencode |
| from libcloud.utils.xml import findall, findtext, fixxpath |
| from libcloud.common.aws import AWSGenericResponse, AWSTokenConnection |
| from libcloud.common.base import ConnectionUserAndKey |
| from libcloud.common.types import LibcloudError |
| |
| API_VERSION = "2012-02-29" |
| API_HOST = "route53.amazonaws.com" |
| API_ROOT = "/%s/" % (API_VERSION) |
| |
| NAMESPACE = "https://{}/doc{}".format(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("{}={}".format(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().__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 = "{} {}".format(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) |
| yield from items |
| |
| 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()._ex_connection_class_kwargs() |
| kwargs["token"] = self.token |
| return kwargs |
| |
| def _quote_data(self, data): |
| if data[0] == '"' and data[-1] == '"': |
| return data |
| return '"{}"'.format(data.replace('"', '"')) |