mirror of
				https://github.com/github/octodns.git
				synced 2024-05-11 05:55:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			414 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			414 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| from __future__ import absolute_import, division, print_function, \
 | |
|     unicode_literals
 | |
| 
 | |
| from requests import HTTPError, Session
 | |
| import logging
 | |
| 
 | |
| from ..record import Create, Record
 | |
| from .base import BaseProvider
 | |
| 
 | |
| 
 | |
| class PowerDnsBaseProvider(BaseProvider):
 | |
|     SUPPORTS_GEO = False
 | |
|     SUPPORTS_DYNAMIC = False
 | |
|     SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
 | |
|                     'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
 | |
|     TIMEOUT = 5
 | |
| 
 | |
|     def __init__(self, id, host, api_key, port=8081, scheme="http",
 | |
|                  timeout=TIMEOUT, *args, **kwargs):
 | |
|         super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs)
 | |
| 
 | |
|         self.host = host
 | |
|         self.port = port
 | |
|         self.scheme = scheme
 | |
|         self.timeout = timeout
 | |
| 
 | |
|         sess = Session()
 | |
|         sess.headers.update({'X-API-Key': api_key})
 | |
|         self._sess = sess
 | |
| 
 | |
|     def _request(self, method, path, data=None):
 | |
|         self.log.debug('_request: method=%s, path=%s', method, path)
 | |
| 
 | |
|         url = '{}://{}:{}/api/v1/servers/localhost/{}' \
 | |
|             .format(self.scheme, self.host, self.port, path)
 | |
|         resp = self._sess.request(method, url, json=data, timeout=self.timeout)
 | |
|         self.log.debug('_request:   status=%d', resp.status_code)
 | |
|         resp.raise_for_status()
 | |
|         return resp
 | |
| 
 | |
|     def _get(self, path, data=None):
 | |
|         return self._request('GET', path, data=data)
 | |
| 
 | |
|     def _post(self, path, data=None):
 | |
|         return self._request('POST', path, data=data)
 | |
| 
 | |
|     def _patch(self, path, data=None):
 | |
|         return self._request('PATCH', path, data=data)
 | |
| 
 | |
|     def _data_for_multiple(self, rrset):
 | |
|         # TODO: geo not supported
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': [r['content'] for r in rrset['records']],
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     _data_for_A = _data_for_multiple
 | |
|     _data_for_AAAA = _data_for_multiple
 | |
|     _data_for_NS = _data_for_multiple
 | |
| 
 | |
|     def _data_for_CAA(self, rrset):
 | |
|         values = []
 | |
|         for record in rrset['records']:
 | |
|             flags, tag, value = record['content'].split(' ', 2)
 | |
|             values.append({
 | |
|                 'flags': flags,
 | |
|                 'tag': tag,
 | |
|                 'value': value[1:-1],
 | |
|             })
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': values,
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     def _data_for_single(self, rrset):
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'value': rrset['records'][0]['content'],
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     _data_for_ALIAS = _data_for_single
 | |
|     _data_for_CNAME = _data_for_single
 | |
|     _data_for_PTR = _data_for_single
 | |
| 
 | |
|     def _data_for_quoted(self, rrset):
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': [r['content'][1:-1] for r in rrset['records']],
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     _data_for_SPF = _data_for_quoted
 | |
|     _data_for_TXT = _data_for_quoted
 | |
| 
 | |
|     def _data_for_MX(self, rrset):
 | |
|         values = []
 | |
|         for record in rrset['records']:
 | |
|             preference, exchange = record['content'].split(' ', 1)
 | |
|             values.append({
 | |
|                 'preference': preference,
 | |
|                 'exchange': exchange,
 | |
|             })
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': values,
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     def _data_for_NAPTR(self, rrset):
 | |
|         values = []
 | |
|         for record in rrset['records']:
 | |
|             order, preference, flags, service, regexp, replacement = \
 | |
|                 record['content'].split(' ', 5)
 | |
|             values.append({
 | |
|                 'order': order,
 | |
|                 'preference': preference,
 | |
|                 'flags': flags[1:-1],
 | |
|                 'service': service[1:-1],
 | |
|                 'regexp': regexp[1:-1],
 | |
|                 'replacement': replacement,
 | |
|             })
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': values,
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     def _data_for_SSHFP(self, rrset):
 | |
|         values = []
 | |
|         for record in rrset['records']:
 | |
|             algorithm, fingerprint_type, fingerprint = \
 | |
|                 record['content'].split(' ', 2)
 | |
|             values.append({
 | |
|                 'algorithm': algorithm,
 | |
|                 'fingerprint_type': fingerprint_type,
 | |
|                 'fingerprint': fingerprint,
 | |
|             })
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': values,
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     def _data_for_SRV(self, rrset):
 | |
|         values = []
 | |
|         for record in rrset['records']:
 | |
|             priority, weight, port, target = \
 | |
|                 record['content'].split(' ', 3)
 | |
|             values.append({
 | |
|                 'priority': priority,
 | |
|                 'weight': weight,
 | |
|                 'port': port,
 | |
|                 'target': target,
 | |
|             })
 | |
|         return {
 | |
|             'type': rrset['type'],
 | |
|             'values': values,
 | |
|             'ttl': rrset['ttl']
 | |
|         }
 | |
| 
 | |
|     def populate(self, zone, target=False, lenient=False):
 | |
|         self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
 | |
|                        target, lenient)
 | |
| 
 | |
|         resp = None
 | |
|         try:
 | |
|             resp = self._get('zones/{}'.format(zone.name))
 | |
|             self.log.debug('populate:   loaded')
 | |
|         except HTTPError as e:
 | |
|             if e.response.status_code == 401:
 | |
|                 # Nicer error message for auth problems
 | |
|                 raise Exception('PowerDNS unauthorized host={}'
 | |
|                                 .format(self.host))
 | |
|             elif e.response.status_code == 422:
 | |
|                 # 422 means powerdns doesn't know anything about the requested
 | |
|                 # domain. We'll just ignore it here and leave the zone
 | |
|                 # untouched.
 | |
|                 pass
 | |
|             else:
 | |
|                 # just re-throw
 | |
|                 raise
 | |
| 
 | |
|         before = len(zone.records)
 | |
|         exists = False
 | |
| 
 | |
|         if resp:
 | |
|             exists = True
 | |
|             for rrset in resp.json()['rrsets']:
 | |
|                 _type = rrset['type']
 | |
|                 if _type == 'SOA':
 | |
|                     continue
 | |
|                 data_for = getattr(self, '_data_for_{}'.format(_type))
 | |
|                 record_name = zone.hostname_from_fqdn(rrset['name'])
 | |
|                 record = Record.new(zone, record_name, data_for(rrset),
 | |
|                                     source=self, lenient=lenient)
 | |
|                 zone.add_record(record, lenient=lenient)
 | |
| 
 | |
|         self.log.info('populate:   found %s records, exists=%s',
 | |
|                       len(zone.records) - before, exists)
 | |
|         return exists
 | |
| 
 | |
|     def _records_for_multiple(self, record):
 | |
|         return [{'content': v, 'disabled': False}
 | |
|                 for v in record.values]
 | |
| 
 | |
|     _records_for_A = _records_for_multiple
 | |
|     _records_for_AAAA = _records_for_multiple
 | |
|     _records_for_NS = _records_for_multiple
 | |
| 
 | |
|     def _records_for_CAA(self, record):
 | |
|         return [{
 | |
|             'content': '{} {} "{}"'.format(v.flags, v.tag, v.value),
 | |
|             'disabled': False
 | |
|         } for v in record.values]
 | |
| 
 | |
|     def _records_for_single(self, record):
 | |
|         return [{'content': record.value, 'disabled': False}]
 | |
| 
 | |
|     _records_for_ALIAS = _records_for_single
 | |
|     _records_for_CNAME = _records_for_single
 | |
|     _records_for_PTR = _records_for_single
 | |
| 
 | |
|     def _records_for_quoted(self, record):
 | |
|         return [{'content': '"{}"'.format(v), 'disabled': False}
 | |
|                 for v in record.values]
 | |
| 
 | |
|     _records_for_SPF = _records_for_quoted
 | |
|     _records_for_TXT = _records_for_quoted
 | |
| 
 | |
|     def _records_for_MX(self, record):
 | |
|         return [{
 | |
|             'content': '{} {}'.format(v.preference, v.exchange),
 | |
|             'disabled': False
 | |
|         } for v in record.values]
 | |
| 
 | |
|     def _records_for_NAPTR(self, record):
 | |
|         return [{
 | |
|             'content': '{} {} "{}" "{}" "{}" {}'.format(v.order, v.preference,
 | |
|                                                         v.flags, v.service,
 | |
|                                                         v.regexp,
 | |
|                                                         v.replacement),
 | |
|             'disabled': False
 | |
|         } for v in record.values]
 | |
| 
 | |
|     def _records_for_SSHFP(self, record):
 | |
|         return [{
 | |
|             'content': '{} {} {}'.format(v.algorithm, v.fingerprint_type,
 | |
|                                          v.fingerprint),
 | |
|             'disabled': False
 | |
|         } for v in record.values]
 | |
| 
 | |
|     def _records_for_SRV(self, record):
 | |
|         return [{
 | |
|             'content': '{} {} {} {}'.format(v.priority, v.weight, v.port,
 | |
|                                             v.target),
 | |
|             'disabled': False
 | |
|         } for v in record.values]
 | |
| 
 | |
|     def _mod_Create(self, change):
 | |
|         new = change.new
 | |
|         records_for = getattr(self, '_records_for_{}'.format(new._type))
 | |
|         return {
 | |
|             'name': new.fqdn,
 | |
|             'type': new._type,
 | |
|             'ttl': new.ttl,
 | |
|             'changetype': 'REPLACE',
 | |
|             'records': records_for(new)
 | |
|         }
 | |
| 
 | |
|     _mod_Update = _mod_Create
 | |
| 
 | |
|     def _mod_Delete(self, change):
 | |
|         existing = change.existing
 | |
|         records_for = getattr(self, '_records_for_{}'.format(existing._type))
 | |
|         return {
 | |
|             'name': existing.fqdn,
 | |
|             'type': existing._type,
 | |
|             'ttl': existing.ttl,
 | |
|             'changetype': 'DELETE',
 | |
|             'records': records_for(existing)
 | |
|         }
 | |
| 
 | |
|     def _get_nameserver_record(self, existing):
 | |
|         return None
 | |
| 
 | |
|     def _extra_changes(self, existing, **kwargs):
 | |
|         self.log.debug('_extra_changes: zone=%s', existing.name)
 | |
| 
 | |
|         ns = self._get_nameserver_record(existing)
 | |
|         if not ns:
 | |
|             return []
 | |
| 
 | |
|         # sorting mostly to make things deterministic for testing, but in
 | |
|         # theory it let us find what we're after quicker (though sorting would
 | |
|         # be more expensive.)
 | |
|         for record in sorted(existing.records):
 | |
|             if record == ns:
 | |
|                 # We've found the top-level NS record, return any changes
 | |
|                 change = record.changes(ns, self)
 | |
|                 self.log.debug('_extra_changes:   change=%s', change)
 | |
|                 if change:
 | |
|                     # We need to modify an existing record
 | |
|                     return [change]
 | |
|                 # No change is necessary
 | |
|                 return []
 | |
|         # No existing top-level NS
 | |
|         self.log.debug('_extra_changes:   create')
 | |
|         return [Create(ns)]
 | |
| 
 | |
|     def _get_error(self, http_error):
 | |
|         try:
 | |
|             return http_error.response.json()['error']
 | |
|         except Exception:
 | |
|             return ''
 | |
| 
 | |
|     def _apply(self, plan):
 | |
|         desired = plan.desired
 | |
|         changes = plan.changes
 | |
|         self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
 | |
|                        len(changes))
 | |
| 
 | |
|         mods = []
 | |
|         for change in changes:
 | |
|             class_name = change.__class__.__name__
 | |
|             mods.append(getattr(self, '_mod_{}'.format(class_name))(change))
 | |
|         self.log.debug('_apply:   sending change request')
 | |
| 
 | |
|         try:
 | |
|             self._patch('zones/{}'.format(desired.name),
 | |
|                         data={'rrsets': mods})
 | |
|             self.log.debug('_apply:   patched')
 | |
|         except HTTPError as e:
 | |
|             error = self._get_error(e)
 | |
|             if e.response.status_code != 422 or \
 | |
|                not error.startswith('Could not find domain '):
 | |
|                 self.log.error('_apply:   status=%d, text=%s',
 | |
|                                e.response.status_code,
 | |
|                                e.response.text)
 | |
|                 raise
 | |
|             self.log.info('_apply:   creating zone=%s', desired.name)
 | |
|             # 422 means powerdns doesn't know anything about the requested
 | |
|             # domain. We'll try to create it with the correct records instead
 | |
|             # of update. Hopefully all the mods are creates :-)
 | |
|             data = {
 | |
|                 'name': desired.name,
 | |
|                 'kind': 'Master',
 | |
|                 'masters': [],
 | |
|                 'nameservers': [],
 | |
|                 'rrsets': mods,
 | |
|                 'soa_edit_api': 'INCEPTION-INCREMENT',
 | |
|                 'serial': 0,
 | |
|             }
 | |
|             try:
 | |
|                 self._post('zones', data)
 | |
|             except HTTPError as e:
 | |
|                 self.log.error('_apply:   status=%d, text=%s',
 | |
|                                e.response.status_code,
 | |
|                                e.response.text)
 | |
|                 raise
 | |
|             self.log.debug('_apply:   created')
 | |
| 
 | |
|         self.log.debug('_apply:   complete')
 | |
| 
 | |
| 
 | |
| class PowerDnsProvider(PowerDnsBaseProvider):
 | |
|     '''
 | |
|     PowerDNS API v4 Provider
 | |
| 
 | |
|     powerdns:
 | |
|         class: octodns.provider.powerdns.PowerDnsProvider
 | |
|         # The host on which PowerDNS api is listening (required)
 | |
|         host: fqdn
 | |
|         # The api key that grans access (required)
 | |
|         api_key: api-key
 | |
|         # The port on which PowerDNS api is listening (optional, default 8081)
 | |
|         port: 8081
 | |
|         # The nameservers to use for this provider (optional,
 | |
|         #   default unmanaged)
 | |
|         nameserver_values:
 | |
|             - 1.2.3.4.
 | |
|             - 1.2.3.5.
 | |
|         # The nameserver record TTL when managed, (optional, default 600)
 | |
|         nameserver_ttl: 600
 | |
|     '''
 | |
| 
 | |
|     def __init__(self, id, host, api_key, port=8081, nameserver_values=None,
 | |
|                  nameserver_ttl=600, *args, **kwargs):
 | |
|         self.log = logging.getLogger('PowerDnsProvider[{}]'.format(id))
 | |
|         self.log.debug('__init__: id=%s, host=%s, port=%d, '
 | |
|                        'nameserver_values=%s, nameserver_ttl=%d',
 | |
|                        id, host, port, nameserver_values, nameserver_ttl)
 | |
|         super(PowerDnsProvider, self).__init__(id, host=host, api_key=api_key,
 | |
|                                                port=port, *args, **kwargs)
 | |
| 
 | |
|         self.nameserver_values = nameserver_values
 | |
|         self.nameserver_ttl = nameserver_ttl
 | |
| 
 | |
|     def _get_nameserver_record(self, existing):
 | |
|         if self.nameserver_values:
 | |
|             return Record.new(existing, '', {
 | |
|                 'type': 'NS',
 | |
|                 'ttl': self.nameserver_ttl,
 | |
|                 'values': self.nameserver_values,
 | |
|             }, source=self)
 | |
| 
 | |
|         return super(PowerDnsProvider, self)._get_nameserver_record(existing)
 |