| # 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. |
| """ |
| PowerDNS Driver |
| """ |
| import json |
| |
| from libcloud.common.base import ConnectionKey, JsonResponse |
| from libcloud.common.exceptions import BaseHTTPError |
| from libcloud.common.types import InvalidCredsError, MalformedResponseError |
| from libcloud.dns.base import DNSDriver, Zone, Record |
| from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError |
| from libcloud.dns.types import Provider, RecordType |
| from libcloud.utils.py3 import httplib |
| |
| __all__ = [ |
| "PowerDNSDriver", |
| ] |
| |
| |
| class PowerDNSResponse(JsonResponse): |
| def success(self): |
| i = int(self.status) |
| return 200 <= i <= 299 |
| |
| def parse_error(self): |
| if self.status == httplib.UNAUTHORIZED: |
| raise InvalidCredsError("Invalid provider credentials") |
| |
| try: |
| body = self.parse_body() |
| except MalformedResponseError as e: |
| body = "%s: %s" % (e.value, e.body) |
| try: |
| errors = [body["error"]] |
| except TypeError: |
| # parse_body() gave us a simple string, not a dict. |
| return "%s (HTTP Code: %d)" % (body, self.status) |
| try: |
| errors.append(body["errors"]) |
| except KeyError: |
| # The PowerDNS API does not return the "errors" list all the time. |
| pass |
| |
| return "%s (HTTP Code: %d)" % (" ".join(errors), self.status) |
| |
| |
| class PowerDNSConnection(ConnectionKey): |
| responseCls = PowerDNSResponse |
| |
| def add_default_headers(self, headers): |
| headers["X-API-Key"] = self.key |
| return headers |
| |
| |
| class PowerDNSDriver(DNSDriver): |
| type = Provider.POWERDNS |
| name = "PowerDNS" |
| website = "https://www.powerdns.com/" |
| connectionCls = PowerDNSConnection |
| |
| RECORD_TYPE_MAP = { |
| RecordType.A: "A", |
| RecordType.AAAA: "AAAA", |
| RecordType.AFSDB: "AFSDB", |
| RecordType.CERT: "CERT", |
| RecordType.CNAME: "CNAME", |
| RecordType.DNSKEY: "DNSKEY", |
| RecordType.DS: "DS", |
| RecordType.HINFO: "HINFO", |
| RecordType.KEY: "KEY", |
| RecordType.LOC: "LOC", |
| RecordType.MX: "MX", |
| RecordType.NAPTR: "NAPTR", |
| RecordType.NS: "NS", |
| RecordType.NSEC: "NSEC", |
| RecordType.OPENPGPKEY: "OPENPGPKEY", |
| RecordType.PTR: "PTR", |
| RecordType.RP: "RP", |
| RecordType.RRSIG: "RRSIG", |
| RecordType.SOA: "SOA", |
| RecordType.SPF: "SPF", |
| RecordType.SSHFP: "SSHFP", |
| RecordType.SRV: "SRV", |
| RecordType.TLSA: "TLSA", |
| RecordType.TXT: "TXT", |
| } |
| |
| def __init__( |
| self, |
| key, |
| secret=None, |
| secure=False, |
| host=None, |
| port=None, |
| api_version="experimental", |
| **kwargs, |
| ): |
| """ |
| PowerDNS Driver defaulting to using PowerDNS 3.x API (ie |
| "experimental"). |
| |
| :param key: API key or username to used (required) |
| :type key: ``str`` |
| |
| :param secure: Whether to use HTTPS or HTTP. Note: Off by default |
| for PowerDNS. |
| :type secure: ``bool`` |
| |
| :param host: Hostname used for connections. |
| :type host: ``str`` |
| |
| :param port: Port used for connections. |
| :type port: ``int`` |
| |
| :param api_version: Specifies the API version to use. |
| ``experimental`` and ``v1`` are the only valid |
| options. Defaults to using ``experimental`` |
| (optional) |
| :type api_version: ``str`` |
| |
| :return: ``None`` |
| """ |
| # libcloud doesn't really have a concept of "servers". We'll just use |
| # localhost for now. |
| self.ex_server = "localhost" |
| |
| if api_version == "experimental": |
| # PowerDNS 3.x has no API root prefix. |
| self.api_root = "" |
| elif api_version == "v1": |
| # PowerDNS 4.x has an '/api/v1' root prefix. |
| self.api_root = "/api/v1" |
| else: |
| raise NotImplementedError("Unsupported API version: %s" % api_version) |
| |
| super(PowerDNSDriver, self).__init__( |
| key=key, secure=secure, host=host, port=port, **kwargs |
| ) |
| |
| def create_record(self, name, zone, type, data, extra=None): |
| """ |
| Create a new record. |
| |
| There are two PowerDNS-specific quirks here. Firstly, this method will |
| silently clobber any pre-existing records that might already exist. For |
| example, if PowerDNS already contains a "test.example.com" A record, |
| and you create that record using this function, then the old A record |
| will be replaced with your new one. |
| |
| Secondly, PowerDNS requires that you provide a ttl for all new records. |
| In other words, the "extra" parameter must be ``{'ttl': |
| <some-integer>}`` at a minimum. |
| |
| :param name: FQDN of the new record, for example "www.example.com". |
| :type name: ``str`` |
| |
| :param zone: Zone where the requested record is created. |
| :type zone: :class:`Zone` |
| |
| :param type: DNS record type (A, AAAA, ...). |
| :type type: :class:`RecordType` |
| |
| :param data: Data for the record (depends on the record type). |
| :type data: ``str`` |
| |
| :param extra: Extra attributes (driver specific, e.g. 'ttl'). |
| Note that PowerDNS *requires* a ttl value for every |
| record. |
| :type extra: ``dict`` |
| |
| :rtype: :class:`Record` |
| """ |
| extra = extra or {} |
| |
| action = "%s/servers/%s/zones/%s" % (self.api_root, self.ex_server, zone.id) |
| if extra is None or extra.get("ttl", None) is None: |
| raise ValueError("PowerDNS requires a ttl value for every record") |
| |
| if self._pdns_version() == 3: |
| record = { |
| "content": data, |
| "disabled": False, |
| "name": name, |
| "ttl": extra["ttl"], |
| "type": type, |
| } |
| payload = { |
| "rrsets": [ |
| { |
| "name": name, |
| "type": type, |
| "changetype": "REPLACE", |
| "records": [record], |
| } |
| ] |
| } |
| elif self._pdns_version() == 4: |
| record = { |
| "content": data, |
| "disabled": extra.get("disabled", False), |
| "set-ptr": False, |
| } |
| payload = { |
| "rrsets": [ |
| { |
| "name": name, |
| "type": type, |
| "changetype": "REPLACE", |
| "ttl": extra["ttl"], |
| "records": [record], |
| } |
| ] |
| } |
| |
| if "comment" in extra: |
| payload["rrsets"][0]["comments"] = extra["comment"] |
| |
| try: |
| self.connection.request( |
| action=action, data=json.dumps(payload), method="PATCH" |
| ) |
| except BaseHTTPError as e: |
| if e.code == httplib.UNPROCESSABLE_ENTITY and e.message.startswith( |
| "Could not find domain" |
| ): |
| raise ZoneDoesNotExistError( |
| zone_id=zone.id, driver=self, value=e.message |
| ) |
| raise e |
| return Record( |
| id=None, |
| name=name, |
| data=data, |
| type=type, |
| zone=zone, |
| driver=self, |
| ttl=extra["ttl"], |
| ) |
| |
| def create_zone(self, domain, type=None, ttl=None, extra={}): |
| """ |
| Create a new zone. |
| |
| There are two PowerDNS-specific quirks here. Firstly, the "type" and |
| "ttl" parameters are ignored (no-ops). The "type" parameter is simply |
| not implemented, and PowerDNS does not have an ability to set a |
| zone-wide default TTL. (TTLs must be set per-record.) |
| |
| Secondly, PowerDNS requires that you provide a list of nameservers for |
| the zone upon creation. In other words, the "extra" parameter must be |
| ``{'nameservers': ['ns1.example.org']}`` at a minimum. |
| |
| :param name: Zone domain name (e.g. example.com) |
| :type name: ``str`` |
| |
| :param domain: Zone type (master / slave). (optional). Note that the |
| PowerDNS driver does nothing with this parameter. |
| :type domain: :class:`Zone` |
| |
| :param ttl: TTL for new records. (optional). Note that the PowerDNS |
| driver does nothing with this parameter. |
| :type ttl: ``int`` |
| |
| :param extra: Extra attributes (driver specific). |
| For example, specify |
| ``extra={'nameservers': ['ns1.example.org']}`` to set |
| a list of nameservers for this new zone. |
| :type extra: ``dict`` |
| |
| :rtype: :class:`Zone` |
| """ |
| action = "%s/servers/%s/zones" % (self.api_root, self.ex_server) |
| if extra is None or extra.get("nameservers", None) is None: |
| msg = "PowerDNS requires a list of nameservers for every new zone" |
| raise ValueError(msg) |
| payload = {"name": domain, "kind": "Native"} |
| payload.update(extra) |
| zone_id = domain + "." |
| try: |
| self.connection.request( |
| action=action, data=json.dumps(payload), method="POST" |
| ) |
| except BaseHTTPError as e: |
| if e.code == httplib.UNPROCESSABLE_ENTITY and e.message.startswith( |
| "Domain '%s' already exists" % domain |
| ): |
| raise ZoneAlreadyExistsError( |
| zone_id=zone_id, driver=self, value=e.message |
| ) |
| raise e |
| return Zone( |
| id=zone_id, domain=domain, type=None, ttl=None, driver=self, extra=extra |
| ) |
| |
| def delete_record(self, record): |
| """ |
| Use this method to delete a record. |
| |
| :param record: record to delete |
| :type record: `Record` |
| |
| :rtype: ``bool`` |
| """ |
| action = "%s/servers/%s/zones/%s" % ( |
| self.api_root, |
| self.ex_server, |
| record.zone.id, |
| ) |
| payload = { |
| "rrsets": [ |
| {"name": record.name, "type": record.type, "changetype": "DELETE"} |
| ] |
| } |
| try: |
| self.connection.request( |
| action=action, data=json.dumps(payload), method="PATCH" |
| ) |
| except BaseHTTPError: |
| # I'm not sure if we should raise a ZoneDoesNotExistError here. The |
| # base DNS API only specifies that we should return a bool. So, |
| # let's ignore this code for now. |
| # if e.code == httplib.UNPROCESSABLE_ENTITY and \ |
| # e.message.startswith('Could not find domain'): |
| # raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, |
| # value=e.message) |
| # raise e |
| return False |
| return True |
| |
| def delete_zone(self, zone): |
| """ |
| Use this method to delete a zone. |
| |
| :param zone: zone to delete |
| :type zone: `Zone` |
| |
| :rtype: ``bool`` |
| """ |
| action = "%s/servers/%s/zones/%s" % (self.api_root, self.ex_server, zone.id) |
| try: |
| self.connection.request(action=action, method="DELETE") |
| except BaseHTTPError: |
| # I'm not sure if we should raise a ZoneDoesNotExistError here. The |
| # base DNS API only specifies that we should return a bool. So, |
| # let's ignore this code for now. |
| # if e.code == httplib.UNPROCESSABLE_ENTITY and \ |
| # e.message.startswith('Could not find domain'): |
| # raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, |
| # value=e.message) |
| # raise e |
| return False |
| return True |
| |
| def get_zone(self, zone_id): |
| """ |
| Return a Zone instance. |
| |
| (Note that PowerDNS does not support per-zone TTL defaults, so all Zone |
| objects will have ``ttl=None``.) |
| |
| :param zone_id: name of the required zone with the trailing period, for |
| example "example.com.". |
| :type zone_id: ``str`` |
| |
| :rtype: :class:`Zone` |
| :raises: ZoneDoesNotExistError: If no zone could be found. |
| """ |
| action = "%s/servers/%s/zones/%s" % (self.api_root, self.ex_server, zone_id) |
| try: |
| response = self.connection.request(action=action, method="GET") |
| except BaseHTTPError as e: |
| if e.code == httplib.UNPROCESSABLE_ENTITY: |
| raise ZoneDoesNotExistError( |
| zone_id=zone_id, driver=self, value=e.message |
| ) |
| raise e |
| return self._to_zone(response.object) |
| |
| def list_records(self, zone): |
| """ |
| Return a list of all records for the provided zone. |
| |
| :param zone: Zone to list records for. |
| :type zone: :class:`Zone` |
| |
| :return: ``list`` of :class:`Record` |
| """ |
| action = "%s/servers/%s/zones/%s" % (self.api_root, self.ex_server, zone.id) |
| try: |
| response = self.connection.request(action=action, method="GET") |
| except BaseHTTPError as e: |
| if e.code == httplib.UNPROCESSABLE_ENTITY and e.message.startswith( |
| "Could not find domain" |
| ): |
| raise ZoneDoesNotExistError( |
| zone_id=zone.id, driver=self, value=e.message |
| ) |
| raise e |
| return self._to_records(response, zone) |
| |
| def list_zones(self): |
| """ |
| Return a list of zones. |
| |
| :return: ``list`` of :class:`Zone` |
| """ |
| action = "%s/servers/%s/zones" % (self.api_root, self.ex_server) |
| response = self.connection.request(action=action, method="GET") |
| return self._to_zones(response) |
| |
| def update_record(self, record, name, type, data, extra=None): |
| """ |
| Update an existing record. |
| |
| :param record: Record to update. |
| :type record: :class:`Record` |
| |
| :param name: FQDN of the new record, for example "www.example.com". |
| :type name: ``str`` |
| |
| :param type: DNS record type (A, AAAA, ...). |
| :type type: :class:`RecordType` |
| |
| :param data: Data for the record (depends on the record type). |
| :type data: ``str`` |
| |
| :param extra: (optional) Extra attributes (driver specific). |
| :type extra: ``dict`` |
| |
| :rtype: :class:`Record` |
| """ |
| action = "%s/servers/%s/zones/%s" % ( |
| self.api_root, |
| self.ex_server, |
| record.zone.id, |
| ) |
| if extra is None or extra.get("ttl", None) is None: |
| raise ValueError("PowerDNS requires a ttl value for every record") |
| |
| if self._pdns_version() == 3: |
| updated_record = { |
| "content": data, |
| "disabled": False, |
| "name": name, |
| "ttl": extra["ttl"], |
| "type": type, |
| } |
| payload = { |
| "rrsets": [ |
| {"name": record.name, "type": record.type, "changetype": "DELETE"}, |
| { |
| "name": name, |
| "type": type, |
| "changetype": "REPLACE", |
| "records": [updated_record], |
| }, |
| ] |
| } |
| elif self._pdns_version() == 4: |
| disabled = False |
| if "disabled" in extra: |
| disabled = extra["disabled"] |
| updated_record = { |
| "content": data, |
| "disabled": disabled, |
| "set-ptr": False, |
| } |
| payload = { |
| "rrsets": [ |
| { |
| "name": name, |
| "type": type, |
| "changetype": "REPLACE", |
| "ttl": extra["ttl"], |
| "records": [updated_record], |
| } |
| ] |
| } |
| |
| if "comment" in extra: |
| payload["rrsets"][0]["comments"] = extra["comment"] |
| |
| try: |
| self.connection.request( |
| action=action, data=json.dumps(payload), method="PATCH" |
| ) |
| except BaseHTTPError as e: |
| if e.code == httplib.UNPROCESSABLE_ENTITY and e.message.startswith( |
| "Could not find domain" |
| ): |
| raise ZoneDoesNotExistError( |
| zone_id=record.zone.id, driver=self, value=e.message |
| ) |
| raise e |
| return Record( |
| id=None, |
| name=name, |
| data=data, |
| type=type, |
| zone=record.zone, |
| driver=self, |
| ttl=extra["ttl"], |
| ) |
| |
| def _to_zone(self, item): |
| extra = {} |
| for e in [ |
| "kind", |
| "dnssec", |
| "account", |
| "masters", |
| "serial", |
| "notified_serial", |
| "last_check", |
| ]: |
| extra[e] = item[e] |
| # XXX: we have to hard-code "ttl" to "None" here because PowerDNS does |
| # not support per-zone ttl defaults. However, I don't know what "type" |
| # should be; probably not None. |
| return Zone( |
| id=item["id"], |
| domain=item["name"], |
| type=None, |
| ttl=None, |
| driver=self, |
| extra=extra, |
| ) |
| |
| def _to_zones(self, items): |
| zones = [] |
| for item in items.object: |
| zones.append(self._to_zone(item)) |
| return zones |
| |
| def _to_record(self, item, zone, record=None): |
| if record is None: |
| data = item["content"] |
| else: |
| data = record["content"] |
| return Record( |
| id=None, |
| name=item["name"], |
| data=data, |
| type=item["type"], |
| zone=zone, |
| driver=self, |
| ttl=item["ttl"], |
| ) |
| |
| def _to_records(self, items, zone): |
| records = [] |
| if self._pdns_version() == 3: |
| for item in items.object["records"]: |
| records.append(self._to_record(item, zone)) |
| elif self._pdns_version() == 4: |
| for item in items.object["rrsets"]: |
| for record in item["records"]: |
| records.append(self._to_record(item, zone, record)) |
| return records |
| |
| def _pdns_version(self): |
| if self.api_root == "": |
| return 3 |
| elif self.api_root == "/api/v1": |
| return 4 |
| |
| raise ValueError("PowerDNS version has not been declared") |