# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from requests import Session from akamai.edgegrid import EdgeGridAuth from collections import defaultdict from urllib.parse import urljoin from logging import getLogger from ..record import Record from . import ProviderException from .base import BaseProvider class AkamaiClientNotFound(ProviderException): def __init__(self, resp): message = "404: Resource not found" super(AkamaiClientNotFound, self).__init__(message) class AkamaiClient(object): ''' Client for making calls to Akamai Fast DNS API using Python Requests Edge DNS Zone Management API V2, found here: https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html Info on Python Requests library: https://2.python-requests.org/en/master/ ''' def __init__(self, client_secret, host, access_token, client_token): self.base = "https://" + host + "/config-dns/v2/" sess = Session() sess.auth = EdgeGridAuth( client_token=client_token, client_secret=client_secret, access_token=access_token ) self._sess = sess def _request(self, method, path, params=None, data=None, v1=False): url = urljoin(self.base, path) resp = self._sess.request(method, url, params=params, json=data) if resp.status_code == 404: raise AkamaiClientNotFound(resp) resp.raise_for_status() return resp def record_create(self, zone, name, record_type, content): path = f'zones/{zone}/names/{name}/types/{record_type}' result = self._request('POST', path, data=content) return result def record_delete(self, zone, name, record_type): path = f'zones/{zone}/names/{name}/types/{record_type}' result = self._request('DELETE', path) return result def record_replace(self, zone, name, record_type, content): path = f'zones/{zone}/names/{name}/types/{record_type}' result = self._request('PUT', path, data=content) return result def zone_get(self, zone): path = f'zones/{zone}' result = self._request('GET', path) return result def zone_create(self, contractId, params, gid=None): path = f'zones?contractId={contractId}' if gid is not None: path += f'&gid={gid}' result = self._request('POST', path, data=params) return result def zone_recordset_get(self, zone, page=None, pageSize=None, search=None, showAll="true", sortBy="name", types=None): params = { 'page': page, 'pageSize': pageSize, 'search': search, 'showAll': showAll, 'sortBy': sortBy, 'types': types } path = f'zones/{zone}/recordsets' result = self._request('GET', path, params=params) return result class AkamaiProvider(BaseProvider): ''' Akamai Edge DNS Provider edgedns.py: Example config file with variables: " --- providers: config: class: octodns.provider.yaml.YamlProvider directory: ./config (example path to directory of zone files) edgedns: class: octodns.provider.edgedns.AkamaiProvider client_secret: env/AKAMAI_CLIENT_SECRET host: env/AKAMAI_HOST access_token: env/AKAMAI_ACCESS_TOKEN client_token: env/AKAMAI_CLIENT_TOKEN contract_id: env/AKAMAI_CONTRACT_ID (optional) zones: example.com.: sources: - config targets: - edgedns " The first four variables above can be hidden in environment variables and octoDNS will automatically search for them in the shell. It is possible to also hard-code into the config file: eg, contract_id. The first four values can be found by generating credentials: https://control.akamai.com/ Configure > Organization > Manage APIs > New API Client for me Select appropriate group, and fill relevant fields. For API Service Name, select DNS-Zone Record Management and then set appropriate Access level (Read-Write to make changes). Then select the "New Credential" button to generate values for above The contract_id paramater is optional, and only required for creating a new zone. If the zone being managed already exists in Akamai for the user in question, then this paramater is not needed. ''' SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT')) def __init__(self, id, client_secret, host, access_token, client_token, contract_id=None, gid=None, *args, **kwargs): self.log = getLogger(f'AkamaiProvider[{id}]') self.log.debug('__init__: id=%s, ') super(AkamaiProvider, self).__init__(id, *args, **kwargs) self._dns_client = AkamaiClient(client_secret, host, access_token, client_token) self._zone_records = {} self._contractId = contract_id self._gid = gid def zone_records(self, zone): """ returns records for a zone, looks for it if not present, or returns empty [] if can't find a match """ if zone.name not in self._zone_records: try: name = zone.name[:-1] response = self._dns_client.zone_recordset_get(name) self._zone_records[zone.name] = response.json()["recordsets"] except (AkamaiClientNotFound, KeyError): return [] return self._zone_records[zone.name] def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s', zone.name) values = defaultdict(lambda: defaultdict(list)) for record in self.zone_records(zone): _type = record.get('type') # Akamai sends down prefix.zonename., while octodns expects prefix _name = record.get('name').split("." + zone.name[:-1], 1)[0] if _name == zone.name[:-1]: _name = '' # root / @ if _type not in self.SUPPORTS: continue values[_name][_type].append(record) before = len(zone.records) for name, types in values.items(): for _type, records in types.items(): data_for = getattr(self, f'_data_for_{_type}') record = Record.new(zone, name, data_for(_type, records[0]), source=self, lenient=lenient) zone.add_record(record, lenient=lenient) exists = zone.name in self._zone_records found = len(zone.records) - before self.log.info('populate: found %s records, exists=%s', found, exists) return exists def _apply(self, plan): desired = plan.desired changes = plan.changes self.log.debug('apply: zone=%s, chnges=%d', desired.name, len(changes)) zone_name = desired.name[:-1] try: self._dns_client.zone_get(zone_name) except AkamaiClientNotFound: self.log.info("zone not found, creating zone") params = self._build_zone_config(zone_name) self._dns_client.zone_create(self._contractId, params, self._gid) for change in changes: class_name = change.__class__.__name__ getattr(self, f'_apply_{class_name}')(change) # Clear out the cache if any self._zone_records.pop(desired.name, None) def _apply_Create(self, change): new = change.new record_type = new._type params_for = getattr(self, f'_params_for_{record_type}') values = self._get_values(new.data) rdata = params_for(values) zone = new.zone.name[:-1] name = self._set_full_name(new.name, zone) content = { "name": name, "type": record_type, "ttl": new.ttl, "rdata": rdata } self._dns_client.record_create(zone, name, record_type, content) return def _apply_Delete(self, change): zone = change.existing.zone.name[:-1] name = self._set_full_name(change.existing.name, zone) record_type = change.existing._type self._dns_client.record_delete(zone, name, record_type) return def _apply_Update(self, change): new = change.new record_type = new._type params_for = getattr(self, f'_params_for_{record_type}') values = self._get_values(new.data) rdata = params_for(values) zone = new.zone.name[:-1] name = self._set_full_name(new.name, zone) content = { "name": name, "type": record_type, "ttl": new.ttl, "rdata": rdata } self._dns_client.record_replace(zone, name, record_type, content) return def _data_for_multiple(self, _type, records): return { 'ttl': records['ttl'], 'type': _type, 'values': [r for r in records['rdata']] } _data_for_A = _data_for_multiple _data_for_AAAA = _data_for_multiple _data_for_NS = _data_for_multiple _data_for_SPF = _data_for_multiple def _data_for_CNAME(self, _type, records): value = records['rdata'][0] if (value[-1] != '.'): value = f'{value}.' return { 'ttl': records['ttl'], 'type': _type, 'value': value } def _data_for_MX(self, _type, records): values = [] for r in records['rdata']: preference, exchange = r.split(" ", 1) values.append({ 'preference': preference, 'exchange': exchange }) return { 'ttl': records['ttl'], 'type': _type, 'values': values } def _data_for_NAPTR(self, _type, records): values = [] for r in records['rdata']: order, preference, flags, service, regexp, repl = r.split(' ', 5) values.append({ 'flags': flags[1:-1], 'order': order, 'preference': preference, 'regexp': regexp[1:-1], 'replacement': repl, 'service': service[1:-1] }) return { 'type': _type, 'ttl': records['ttl'], 'values': values } def _data_for_PTR(self, _type, records): return { 'ttl': records['ttl'], 'type': _type, 'value': records['rdata'][0] } def _data_for_SRV(self, _type, records): values = [] for r in records['rdata']: priority, weight, port, target = r.split(' ', 3) values.append({ 'port': port, 'priority': priority, 'target': target, 'weight': weight }) return { 'type': _type, 'ttl': records['ttl'], 'values': values } def _data_for_SSHFP(self, _type, records): values = [] for r in records['rdata']: algorithm, fp_type, fingerprint = r.split(' ', 2) values.append({ 'algorithm': algorithm, 'fingerprint': fingerprint.lower(), 'fingerprint_type': fp_type }) return { 'type': _type, 'ttl': records['ttl'], 'values': values } def _data_for_TXT(self, _type, records): values = [] for r in records['rdata']: r = r[1:-1] values.append(r.replace(';', '\\;')) return { 'ttl': records['ttl'], 'type': _type, 'values': values } def _params_for_multiple(self, values): return [r for r in values] def _params_for_single(self, values): return values _params_for_A = _params_for_multiple _params_for_AAAA = _params_for_multiple _params_for_NS = _params_for_multiple _params_for_CNAME = _params_for_single _params_for_PTR = _params_for_single def _params_for_MX(self, values): rdata = [] for r in values: preference = r['preference'] exchange = r['exchange'] rdata.append(f'{preference} {exchange}') return rdata def _params_for_NAPTR(self, values): rdata = [] for r in values: ordr = r['order'] prf = r['preference'] flg = "\"" + r['flags'] + "\"" srvc = "\"" + r['service'] + "\"" rgx = "\"" + r['regexp'] + "\"" rpl = r['replacement'] rdata.append(f'{ordr} {prf} {flg} {srvc} {rgx} {rpl}') return rdata def _params_for_SPF(self, values): rdata = [] for r in values: txt = "\"" + r.replace('\\;', ';') + "\"" rdata.append(txt) return rdata def _params_for_SRV(self, values): rdata = [] for r in values: priority = r['priority'] weight = r['weight'] port = r['port'] target = r['target'] rdata.append(f'{priority} {weight} {port} {target}') return rdata def _params_for_SSHFP(self, values): rdata = [] for r in values: algorithm = r['algorithm'] fp_type = r['fingerprint_type'] fp = r['fingerprint'] rdata.append(f'{algorithm} {fp_type} {fp}') return rdata def _params_for_TXT(self, values): rdata = [] for r in values: txt = "\"" + r.replace('\\;', ';') + "\"" rdata.append(txt) return rdata def _build_zone_config(self, zone, _type="primary", comment=None, masters=[]): if self._contractId is None: raise NameError("contractId not specified to create zone") return { "zone": zone, "type": _type, "comment": comment, "masters": masters } def _get_values(self, data): try: vals = data['values'] except KeyError: vals = [data['value']] return vals def _set_full_name(self, name, zone): name = name + '.' + zone # octodns's name for root is '' if (name[0] == '.'): name = name[1:] return name