mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
Supports ALIAS for Dnsimple, Dyn, Ns1, and PowerDNS. Notes added to readme about some of the quirks found while working with them. TTL seems to mostly be accepted on ALIAS records so it has been added back, what it means seems to vary across providers, thus notes.
383 lines
13 KiB
Python
383 lines
13 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
|
|
TIMEOUT = 5
|
|
|
|
def __init__(self, id, host, api_key, port=8081, *args, **kwargs):
|
|
super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs)
|
|
|
|
self.host = host
|
|
self.port = port
|
|
|
|
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 = 'http://{}:{}/api/v1/servers/localhost/{}' \
|
|
.format(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_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']:
|
|
priority, value = record['content'].split(' ', 1)
|
|
values.append({
|
|
'priority': priority,
|
|
'value': value,
|
|
})
|
|
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):
|
|
self.log.debug('populate: name=%s', zone.name)
|
|
|
|
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 requsted
|
|
# domain. We'll just ignore it here and leave the zone
|
|
# untouched.
|
|
pass
|
|
else:
|
|
# just re-throw
|
|
raise
|
|
|
|
before = len(zone.records)
|
|
|
|
if resp:
|
|
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)
|
|
zone.add_record(record)
|
|
|
|
self.log.info('populate: found %s records',
|
|
len(zone.records) - before)
|
|
|
|
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_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.priority, v.value),
|
|
'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, _):
|
|
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 quickier (though sorting would
|
|
# ve more exepensive.)
|
|
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 requsted
|
|
# 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)
|