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)
|