mirror of
				https://github.com/github/octodns.git
				synced 2024-05-11 05:55:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			378 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			378 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
#
 | 
						|
#
 | 
						|
#
 | 
						|
 | 
						|
from __future__ import absolute_import, division, print_function, \
 | 
						|
    unicode_literals
 | 
						|
 | 
						|
from collections import defaultdict
 | 
						|
from requests import Session
 | 
						|
import logging
 | 
						|
 | 
						|
from ..record import Record
 | 
						|
from . import ProviderException
 | 
						|
from .base import BaseProvider
 | 
						|
 | 
						|
 | 
						|
class GandiClientException(ProviderException):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
class GandiClientBadRequest(GandiClientException):
 | 
						|
 | 
						|
    def __init__(self, r):
 | 
						|
        super(GandiClientBadRequest, self).__init__(r.text)
 | 
						|
 | 
						|
 | 
						|
class GandiClientUnauthorized(GandiClientException):
 | 
						|
 | 
						|
    def __init__(self, r):
 | 
						|
        super(GandiClientUnauthorized, self).__init__(r.text)
 | 
						|
 | 
						|
 | 
						|
class GandiClientForbidden(GandiClientException):
 | 
						|
 | 
						|
    def __init__(self, r):
 | 
						|
        super(GandiClientForbidden, self).__init__(r.text)
 | 
						|
 | 
						|
 | 
						|
class GandiClientNotFound(GandiClientException):
 | 
						|
 | 
						|
    def __init__(self, r):
 | 
						|
        super(GandiClientNotFound, self).__init__(r.text)
 | 
						|
 | 
						|
 | 
						|
class GandiClientUnknownDomainName(GandiClientException):
 | 
						|
 | 
						|
    def __init__(self, msg):
 | 
						|
        super(GandiClientUnknownDomainName, self).__init__(msg)
 | 
						|
 | 
						|
 | 
						|
class GandiClient(object):
 | 
						|
 | 
						|
    def __init__(self, token):
 | 
						|
        session = Session()
 | 
						|
        session.headers.update({'Authorization': f'Apikey {token}'})
 | 
						|
        self._session = session
 | 
						|
        self.endpoint = 'https://api.gandi.net/v5'
 | 
						|
 | 
						|
    def _request(self, method, path, params={}, data=None):
 | 
						|
        url = f'{self.endpoint}{path}'
 | 
						|
        r = self._session.request(method, url, params=params, json=data)
 | 
						|
        if r.status_code == 400:
 | 
						|
            raise GandiClientBadRequest(r)
 | 
						|
        if r.status_code == 401:
 | 
						|
            raise GandiClientUnauthorized(r)
 | 
						|
        elif r.status_code == 403:
 | 
						|
            raise GandiClientForbidden(r)
 | 
						|
        elif r.status_code == 404:
 | 
						|
            raise GandiClientNotFound(r)
 | 
						|
        r.raise_for_status()
 | 
						|
        return r
 | 
						|
 | 
						|
    def zone(self, zone_name):
 | 
						|
        return self._request('GET', f'/livedns/domains/{zone_name}').json()
 | 
						|
 | 
						|
    def zone_create(self, zone_name):
 | 
						|
        return self._request('POST', '/livedns/domains', data={
 | 
						|
            'fqdn': zone_name,
 | 
						|
            'zone': {}
 | 
						|
        }).json()
 | 
						|
 | 
						|
    def zone_records(self, zone_name):
 | 
						|
        records = self._request('GET',
 | 
						|
                                f'/livedns/domains/{zone_name}/records').json()
 | 
						|
 | 
						|
        for record in records:
 | 
						|
            if record['rrset_name'] == '@':
 | 
						|
                record['rrset_name'] = ''
 | 
						|
 | 
						|
            # Change relative targets to absolute ones.
 | 
						|
            if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX',
 | 
						|
                                        'NS', 'SRV']:
 | 
						|
                for i, value in enumerate(record['rrset_values']):
 | 
						|
                    if not value.endswith('.'):
 | 
						|
                        record['rrset_values'][i] = f'{value}.{zone_name}.'
 | 
						|
 | 
						|
        return records
 | 
						|
 | 
						|
    def record_create(self, zone_name, data):
 | 
						|
        self._request('POST', f'/livedns/domains/{zone_name}/records',
 | 
						|
                      data=data)
 | 
						|
 | 
						|
    def record_delete(self, zone_name, record_name, record_type):
 | 
						|
        self._request('DELETE', f'/livedns/domains/{zone_name}/records/'
 | 
						|
                      f'{record_name}/{record_type}')
 | 
						|
 | 
						|
 | 
						|
class GandiProvider(BaseProvider):
 | 
						|
    '''
 | 
						|
    Gandi provider using API v5.
 | 
						|
 | 
						|
    gandi:
 | 
						|
        class: octodns.provider.gandi.GandiProvider
 | 
						|
        # Your API key (required)
 | 
						|
        token: XXXXXXXXXXXX
 | 
						|
    '''
 | 
						|
 | 
						|
    SUPPORTS_GEO = False
 | 
						|
    SUPPORTS_DYNAMIC = False
 | 
						|
    SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME',
 | 
						|
                     'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT']))
 | 
						|
 | 
						|
    def __init__(self, id, token, *args, **kwargs):
 | 
						|
        self.log = logging.getLogger(f'GandiProvider[{id}]')
 | 
						|
        self.log.debug('__init__: id=%s, token=***', id)
 | 
						|
        super(GandiProvider, self).__init__(id, *args, **kwargs)
 | 
						|
        self._client = GandiClient(token)
 | 
						|
 | 
						|
        self._zone_records = {}
 | 
						|
 | 
						|
    def _data_for_multiple(self, _type, records):
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'values': [v.replace(';', '\\;') for v in
 | 
						|
                       records[0]['rrset_values']] if _type == 'TXT' else
 | 
						|
            records[0]['rrset_values']
 | 
						|
        }
 | 
						|
 | 
						|
    _data_for_A = _data_for_multiple
 | 
						|
    _data_for_AAAA = _data_for_multiple
 | 
						|
    _data_for_TXT = _data_for_multiple
 | 
						|
    _data_for_SPF = _data_for_multiple
 | 
						|
    _data_for_NS = _data_for_multiple
 | 
						|
 | 
						|
    def _data_for_CAA(self, _type, records):
 | 
						|
        values = []
 | 
						|
        for record in records[0]['rrset_values']:
 | 
						|
            flags, tag, value = record.split(' ')
 | 
						|
            values.append({
 | 
						|
                'flags': flags,
 | 
						|
                'tag': tag,
 | 
						|
                # Remove quotes around value.
 | 
						|
                'value': value[1:-1],
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'values': values
 | 
						|
        }
 | 
						|
 | 
						|
    def _data_for_single(self, _type, records):
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'value': records[0]['rrset_values'][0]
 | 
						|
        }
 | 
						|
 | 
						|
    _data_for_ALIAS = _data_for_single
 | 
						|
    _data_for_CNAME = _data_for_single
 | 
						|
    _data_for_DNAME = _data_for_single
 | 
						|
    _data_for_PTR = _data_for_single
 | 
						|
 | 
						|
    def _data_for_MX(self, _type, records):
 | 
						|
        values = []
 | 
						|
        for record in records[0]['rrset_values']:
 | 
						|
            priority, server = record.split(' ')
 | 
						|
            values.append({
 | 
						|
                'preference': priority,
 | 
						|
                'exchange': server
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'values': values
 | 
						|
        }
 | 
						|
 | 
						|
    def _data_for_SRV(self, _type, records):
 | 
						|
        values = []
 | 
						|
        for record in records[0]['rrset_values']:
 | 
						|
            priority, weight, port, target = record.split(' ', 3)
 | 
						|
            values.append({
 | 
						|
                'priority': priority,
 | 
						|
                'weight': weight,
 | 
						|
                'port': port,
 | 
						|
                'target': target
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'values': values
 | 
						|
        }
 | 
						|
 | 
						|
    def _data_for_SSHFP(self, _type, records):
 | 
						|
        values = []
 | 
						|
        for record in records[0]['rrset_values']:
 | 
						|
            algorithm, fingerprint_type, fingerprint = record.split(' ', 2)
 | 
						|
            values.append({
 | 
						|
                'algorithm': algorithm,
 | 
						|
                'fingerprint': fingerprint,
 | 
						|
                'fingerprint_type': fingerprint_type
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'ttl': records[0]['rrset_ttl'],
 | 
						|
            'type': _type,
 | 
						|
            'values': values
 | 
						|
        }
 | 
						|
 | 
						|
    def zone_records(self, zone):
 | 
						|
        if zone.name not in self._zone_records:
 | 
						|
            try:
 | 
						|
                self._zone_records[zone.name] = \
 | 
						|
                    self._client.zone_records(zone.name[:-1])
 | 
						|
            except GandiClientNotFound:
 | 
						|
                return []
 | 
						|
 | 
						|
        return self._zone_records[zone.name]
 | 
						|
 | 
						|
    def populate(self, zone, target=False, lenient=False):
 | 
						|
        self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
 | 
						|
                       target, lenient)
 | 
						|
 | 
						|
        values = defaultdict(lambda: defaultdict(list))
 | 
						|
        for record in self.zone_records(zone):
 | 
						|
            _type = record['rrset_type']
 | 
						|
            if _type not in self.SUPPORTS:
 | 
						|
                continue
 | 
						|
            values[record['rrset_name']][record['rrset_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),
 | 
						|
                                    source=self, lenient=lenient)
 | 
						|
                zone.add_record(record, lenient=lenient)
 | 
						|
 | 
						|
        exists = zone.name in self._zone_records
 | 
						|
        self.log.info('populate:   found %s records, exists=%s',
 | 
						|
                      len(zone.records) - before, exists)
 | 
						|
        return exists
 | 
						|
 | 
						|
    def _record_name(self, name):
 | 
						|
        return name if name else '@'
 | 
						|
 | 
						|
    def _params_for_multiple(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [v.replace('\\;', ';') for v in
 | 
						|
                             record.values] if record._type == 'TXT'
 | 
						|
            else record.values
 | 
						|
        }
 | 
						|
 | 
						|
    _params_for_A = _params_for_multiple
 | 
						|
    _params_for_AAAA = _params_for_multiple
 | 
						|
    _params_for_NS = _params_for_multiple
 | 
						|
    _params_for_TXT = _params_for_multiple
 | 
						|
    _params_for_SPF = _params_for_multiple
 | 
						|
 | 
						|
    def _params_for_CAA(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [f'{v.flags} {v.tag} "{v.value}"'
 | 
						|
                             for v in record.values]
 | 
						|
        }
 | 
						|
 | 
						|
    def _params_for_single(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [record.value]
 | 
						|
        }
 | 
						|
 | 
						|
    _params_for_ALIAS = _params_for_single
 | 
						|
    _params_for_CNAME = _params_for_single
 | 
						|
    _params_for_DNAME = _params_for_single
 | 
						|
    _params_for_PTR = _params_for_single
 | 
						|
 | 
						|
    def _params_for_MX(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [f'{v.preference} {v.exchange}'
 | 
						|
                             for v in record.values]
 | 
						|
        }
 | 
						|
 | 
						|
    def _params_for_SRV(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [f'{v.priority} {v.weight} {v.port} {v.target}'
 | 
						|
                             for v in record.values]
 | 
						|
        }
 | 
						|
 | 
						|
    def _params_for_SSHFP(self, record):
 | 
						|
        return {
 | 
						|
            'rrset_name': self._record_name(record.name),
 | 
						|
            'rrset_ttl': record.ttl,
 | 
						|
            'rrset_type': record._type,
 | 
						|
            'rrset_values': [f'{v.algorithm} {v.fingerprint_type} '
 | 
						|
                             f'{v.fingerprint}' for v in record.values]
 | 
						|
        }
 | 
						|
 | 
						|
    def _apply_create(self, change):
 | 
						|
        new = change.new
 | 
						|
        data = getattr(self, f'_params_for_{new._type}')(new)
 | 
						|
        self._client.record_create(new.zone.name[:-1], data)
 | 
						|
 | 
						|
    def _apply_update(self, change):
 | 
						|
        self._apply_delete(change)
 | 
						|
        self._apply_create(change)
 | 
						|
 | 
						|
    def _apply_delete(self, change):
 | 
						|
        existing = change.existing
 | 
						|
        zone = existing.zone
 | 
						|
        self._client.record_delete(zone.name[:-1],
 | 
						|
                                   self._record_name(existing.name),
 | 
						|
                                   existing._type)
 | 
						|
 | 
						|
    def _apply(self, plan):
 | 
						|
        desired = plan.desired
 | 
						|
        changes = plan.changes
 | 
						|
        zone = desired.name[:-1]
 | 
						|
        self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
 | 
						|
                       len(changes))
 | 
						|
 | 
						|
        try:
 | 
						|
            self._client.zone(zone)
 | 
						|
        except GandiClientNotFound:
 | 
						|
            self.log.info('_apply: no existing zone, trying to create it')
 | 
						|
            try:
 | 
						|
                self._client.zone_create(zone)
 | 
						|
                self.log.info('_apply: zone has been successfully created')
 | 
						|
            except GandiClientNotFound:
 | 
						|
                # We suppress existing exception before raising
 | 
						|
                # GandiClientUnknownDomainName.
 | 
						|
                e = GandiClientUnknownDomainName('This domain is not '
 | 
						|
                                                 'registered at Gandi. '
 | 
						|
                                                 'Please register or '
 | 
						|
                                                 'transfer it here '
 | 
						|
                                                 'to be able to manage its '
 | 
						|
                                                 'DNS zone.')
 | 
						|
                e.__cause__ = None
 | 
						|
                raise e
 | 
						|
 | 
						|
        # Force records deletion to be done before creation in order to avoid
 | 
						|
        # "CNAME record must be the only record" error when an existing CNAME
 | 
						|
        # record is replaced by an A/AAAA record.
 | 
						|
        changes.reverse()
 | 
						|
 | 
						|
        for change in changes:
 | 
						|
            class_name = change.__class__.__name__
 | 
						|
            getattr(self, f'_apply_{class_name.lower()}')(change)
 | 
						|
 | 
						|
        # Clear out the cache if any
 | 
						|
        self._zone_records.pop(desired.name, None)
 |