|
|
|
@ -5,521 +5,18 @@ |
|
|
|
from __future__ import absolute_import, division, print_function, \ |
|
|
|
unicode_literals |
|
|
|
|
|
|
|
from requests import HTTPError, Session |
|
|
|
from operator import itemgetter |
|
|
|
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', 'LOC', '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 |
|
|
|
|
|
|
|
self._powerdns_version = None |
|
|
|
|
|
|
|
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 = f'{self.scheme}://{self.host}:{self.port}/api/v1/servers/' \ |
|
|
|
f'localhost/{path}'.rstrip('/') |
|
|
|
# Strip trailing / from url. |
|
|
|
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_LOC(self, rrset): |
|
|
|
values = [] |
|
|
|
for record in rrset['records']: |
|
|
|
lat_degrees, lat_minutes, lat_seconds, lat_direction, \ |
|
|
|
long_degrees, long_minutes, long_seconds, long_direction, \ |
|
|
|
altitude, size, precision_horz, precision_vert = \ |
|
|
|
record['content'].replace('m', '').split(' ', 11) |
|
|
|
values.append({ |
|
|
|
'lat_degrees': int(lat_degrees), |
|
|
|
'lat_minutes': int(lat_minutes), |
|
|
|
'lat_seconds': float(lat_seconds), |
|
|
|
'lat_direction': lat_direction, |
|
|
|
'long_degrees': int(long_degrees), |
|
|
|
'long_minutes': int(long_minutes), |
|
|
|
'long_seconds': float(long_seconds), |
|
|
|
'long_direction': long_direction, |
|
|
|
'altitude': float(altitude), |
|
|
|
'size': float(size), |
|
|
|
'precision_horz': float(precision_horz), |
|
|
|
'precision_vert': float(precision_vert), |
|
|
|
}) |
|
|
|
return { |
|
|
|
'ttl': rrset['ttl'], |
|
|
|
'type': rrset['type'], |
|
|
|
'values': values |
|
|
|
} |
|
|
|
|
|
|
|
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'] |
|
|
|
} |
|
|
|
|
|
|
|
@property |
|
|
|
def powerdns_version(self): |
|
|
|
if self._powerdns_version is None: |
|
|
|
try: |
|
|
|
resp = self._get('') |
|
|
|
except HTTPError as e: |
|
|
|
if e.response.status_code == 401: |
|
|
|
# Nicer error message for auth problems |
|
|
|
raise Exception(f'PowerDNS unauthorized host={self.host}') |
|
|
|
raise |
|
|
|
|
|
|
|
version = resp.json()['version'] |
|
|
|
self.log.debug('powerdns_version: got version %s from server', |
|
|
|
version) |
|
|
|
# The extra `-` split is to handle pre-release and source built |
|
|
|
# versions like 4.5.0-alpha0.435.master.gcb114252b |
|
|
|
self._powerdns_version = [ |
|
|
|
int(p.split('-')[0]) for p in version.split('.')[:3]] |
|
|
|
|
|
|
|
return self._powerdns_version |
|
|
|
|
|
|
|
@property |
|
|
|
def soa_edit_api(self): |
|
|
|
# >>> [4, 4, 3] >= [4, 3] |
|
|
|
# True |
|
|
|
# >>> [4, 3, 3] >= [4, 3] |
|
|
|
# True |
|
|
|
# >>> [4, 1, 3] >= [4, 3] |
|
|
|
# False |
|
|
|
if self.powerdns_version >= [4, 3]: |
|
|
|
return 'DEFAULT' |
|
|
|
return 'INCEPTION-INCREMENT' |
|
|
|
|
|
|
|
@property |
|
|
|
def check_status_not_found(self): |
|
|
|
# >=4.2.x returns 404 when not found |
|
|
|
return self.powerdns_version >= [4, 2] |
|
|
|
|
|
|
|
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(f'zones/{zone.name}') |
|
|
|
self.log.debug('populate: loaded') |
|
|
|
except HTTPError as e: |
|
|
|
error = self._get_error(e) |
|
|
|
if e.response.status_code == 401: |
|
|
|
# Nicer error message for auth problems |
|
|
|
raise Exception(f'PowerDNS unauthorized host={self.host}') |
|
|
|
elif e.response.status_code == 404 \ |
|
|
|
and self.check_status_not_found: |
|
|
|
# 404 means powerdns doesn't know anything about the requested |
|
|
|
# domain. We'll just ignore it here and leave the zone |
|
|
|
# untouched. |
|
|
|
pass |
|
|
|
elif e.response.status_code == 422 \ |
|
|
|
and error.startswith('Could not find domain ') \ |
|
|
|
and not self.check_status_not_found: |
|
|
|
# 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, f'_data_for_{_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': f'{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': f'"{v}"', 'disabled': False} |
|
|
|
for v in record.values] |
|
|
|
|
|
|
|
_records_for_SPF = _records_for_quoted |
|
|
|
_records_for_TXT = _records_for_quoted |
|
|
|
|
|
|
|
def _records_for_LOC(self, record): |
|
|
|
return [{ |
|
|
|
'content': |
|
|
|
'%d %d %0.3f %s %d %d %.3f %s %0.2fm %0.2fm %0.2fm %0.2fm' % |
|
|
|
( |
|
|
|
int(v.lat_degrees), |
|
|
|
int(v.lat_minutes), |
|
|
|
float(v.lat_seconds), |
|
|
|
v.lat_direction, |
|
|
|
int(v.long_degrees), |
|
|
|
int(v.long_minutes), |
|
|
|
float(v.long_seconds), |
|
|
|
v.long_direction, |
|
|
|
float(v.altitude), |
|
|
|
float(v.size), |
|
|
|
float(v.precision_horz), |
|
|
|
float(v.precision_vert) |
|
|
|
), |
|
|
|
'disabled': False |
|
|
|
} for v in record.values] |
|
|
|
|
|
|
|
def _records_for_MX(self, record): |
|
|
|
return [{ |
|
|
|
'content': f'{v.preference} {v.exchange}', |
|
|
|
'disabled': False |
|
|
|
} for v in record.values] |
|
|
|
|
|
|
|
def _records_for_NAPTR(self, record): |
|
|
|
return [{ |
|
|
|
'content': f'{v.order} {v.preference} "{v.flags}" "{v.service}" ' |
|
|
|
f'"{v.regexp}" {v.replacement}', |
|
|
|
'disabled': False |
|
|
|
} for v in record.values] |
|
|
|
|
|
|
|
def _records_for_SSHFP(self, record): |
|
|
|
return [{ |
|
|
|
'content': f'{v.algorithm} {v.fingerprint_type} {v.fingerprint}', |
|
|
|
'disabled': False |
|
|
|
} for v in record.values] |
|
|
|
|
|
|
|
def _records_for_SRV(self, record): |
|
|
|
return [{ |
|
|
|
'content': f'{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, f'_records_for_{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, f'_records_for_{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, f'_mod_{class_name}')(change)) |
|
|
|
|
|
|
|
# Ensure that any DELETE modifications always occur before any REPLACE |
|
|
|
# modifications. This ensures that an A record can be replaced by a |
|
|
|
# CNAME record and vice-versa. |
|
|
|
mods.sort(key=itemgetter('changetype')) |
|
|
|
|
|
|
|
self.log.debug('_apply: sending change request') |
|
|
|
|
|
|
|
try: |
|
|
|
self._patch(f'zones/{desired.name}', data={'rrsets': mods}) |
|
|
|
self.log.debug('_apply: patched') |
|
|
|
except HTTPError as e: |
|
|
|
error = self._get_error(e) |
|
|
|
if not ( |
|
|
|
( |
|
|
|
e.response.status_code == 404 and |
|
|
|
self.check_status_not_found |
|
|
|
) or ( |
|
|
|
e.response.status_code == 422 and |
|
|
|
error.startswith('Could not find domain ') and |
|
|
|
not self.check_status_not_found |
|
|
|
) |
|
|
|
): |
|
|
|
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) |
|
|
|
# 404 or 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': self.soa_edit_api, |
|
|
|
'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(f'PowerDnsProvider[{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) |
|
|
|
from logging import getLogger |
|
|
|
|
|
|
|
logger = getLogger('PowerDNS') |
|
|
|
try: |
|
|
|
logger.warn('octodns_powerdns shimmed. Update your provider class to ' |
|
|
|
'octodns_powerdns.PowerDnsProvider. ' |
|
|
|
'Shim will be removed in 1.0') |
|
|
|
from octodns_powerdns import PowerDnsProvider, PowerDnsBaseProvider |
|
|
|
PowerDnsProvider # pragma: no cover |
|
|
|
PowerDnsBaseProvider # pragma: no cover |
|
|
|
except ModuleNotFoundError: |
|
|
|
logger.exception('PowerDnsProvider has been moved into a seperate module, ' |
|
|
|
'octodns_powerdns is now required. Provider class should ' |
|
|
|
'be updated to octodns_powerdns.PowerDnsProvider') |
|
|
|
raise |