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.
562 lines
17 KiB
Python
562 lines
17 KiB
Python
#
|
|
#
|
|
#
|
|
|
|
from __future__ import absolute_import, division, print_function, \
|
|
unicode_literals
|
|
|
|
from ipaddress import IPv4Address, IPv6Address
|
|
from logging import getLogger
|
|
import re
|
|
|
|
|
|
class Change(object):
|
|
|
|
def __init__(self, existing, new):
|
|
self.existing = existing
|
|
self.new = new
|
|
|
|
@property
|
|
def record(self):
|
|
'Returns new if we have one, existing otherwise'
|
|
return self.new or self.existing
|
|
|
|
|
|
class Create(Change):
|
|
|
|
def __init__(self, new):
|
|
super(Create, self).__init__(None, new)
|
|
|
|
def __repr__(self, leader=''):
|
|
source = self.new.source.id if self.new.source else ''
|
|
return 'Create {} ({})'.format(self.new, source)
|
|
|
|
|
|
class Update(Change):
|
|
|
|
# Leader is just to allow us to work around heven eating leading whitespace
|
|
# in our output. When we call this from the Manager.sync plan summary
|
|
# section we'll pass in a leader, otherwise we'll just let it default and
|
|
# do nothing
|
|
def __repr__(self, leader=''):
|
|
source = self.new.source.id if self.new.source else ''
|
|
return 'Update\n{leader} {existing} ->\n{leader} {new} ({src})' \
|
|
.format(existing=self.existing, new=self.new, leader=leader,
|
|
src=source)
|
|
|
|
|
|
class Delete(Change):
|
|
|
|
def __init__(self, existing):
|
|
super(Delete, self).__init__(existing, None)
|
|
|
|
def __repr__(self, leader=''):
|
|
return 'Delete {}'.format(self.existing)
|
|
|
|
|
|
_unescaped_semicolon_re = re.compile(r'\w;')
|
|
|
|
|
|
class Record(object):
|
|
log = getLogger('Record')
|
|
|
|
@classmethod
|
|
def new(cls, zone, name, data, source=None):
|
|
try:
|
|
_type = data['type']
|
|
except KeyError:
|
|
fqdn = '{}.{}'.format(name, zone.name) if name else zone.name
|
|
raise Exception('Invalid record {}, missing type'.format(fqdn))
|
|
try:
|
|
_type = {
|
|
'A': ARecord,
|
|
'AAAA': AaaaRecord,
|
|
'ALIAS': AliasRecord,
|
|
# cert
|
|
'CNAME': CnameRecord,
|
|
# dhcid
|
|
# dname
|
|
# dnskey
|
|
# ds
|
|
# ipseckey
|
|
# key
|
|
# kx
|
|
# loc
|
|
'MX': MxRecord,
|
|
'NAPTR': NaptrRecord,
|
|
'NS': NsRecord,
|
|
# nsap
|
|
'PTR': PtrRecord,
|
|
# px
|
|
# rp
|
|
# soa - would it even make sense?
|
|
'SPF': SpfRecord,
|
|
'SRV': SrvRecord,
|
|
'SSHFP': SshfpRecord,
|
|
'TXT': TxtRecord,
|
|
# url
|
|
}[_type]
|
|
except KeyError:
|
|
raise Exception('Unknown record type: "{}"'.format(_type))
|
|
return _type(zone, name, data, source=source)
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
self.log.debug('__init__: zone.name=%s, type=%11s, name=%s', zone.name,
|
|
self.__class__.__name__, name)
|
|
self.zone = zone
|
|
# force everything lower-case just to be safe
|
|
self.name = str(name).lower() if name else name
|
|
try:
|
|
self.ttl = int(data['ttl'])
|
|
except KeyError:
|
|
raise Exception('Invalid record {}, missing ttl'.format(self.fqdn))
|
|
self.source = source
|
|
|
|
def _data(self):
|
|
return {'ttl': self.ttl}
|
|
|
|
@property
|
|
def data(self):
|
|
return self._data()
|
|
|
|
@property
|
|
def fqdn(self):
|
|
if self.name:
|
|
return '{}.{}'.format(self.name, self.zone.name)
|
|
return self.zone.name
|
|
|
|
def changes(self, other, target):
|
|
# We're assuming we have the same name and type if we're being compared
|
|
if self.ttl != other.ttl:
|
|
return Update(self, other)
|
|
|
|
# NOTE: we're using __hash__ and __cmp__ methods that consider Records
|
|
# equivalent if they have the same name & _type. Values are ignored. This
|
|
# is usful when computing diffs/changes.
|
|
|
|
def __hash__(self):
|
|
return '{}:{}'.format(self.name, self._type).__hash__()
|
|
|
|
def __cmp__(self, other):
|
|
a = '{}:{}'.format(self.name, self._type)
|
|
b = '{}:{}'.format(other.name, other._type)
|
|
return cmp(a, b)
|
|
|
|
def __repr__(self):
|
|
# Make sure this is always overridden
|
|
raise NotImplementedError('Abstract base class, __repr__ required')
|
|
|
|
|
|
class GeoValue(object):
|
|
geo_re = re.compile(r'^(?P<continent_code>\w\w)(-(?P<country_code>\w\w)'
|
|
r'(-(?P<subdivision_code>\w\w))?)?$')
|
|
|
|
def __init__(self, geo, values):
|
|
match = self.geo_re.match(geo)
|
|
if not match:
|
|
raise Exception('Invalid geo "{}"'.format(geo))
|
|
self.code = geo
|
|
self.continent_code = match.group('continent_code')
|
|
self.country_code = match.group('country_code')
|
|
self.subdivision_code = match.group('subdivision_code')
|
|
self.values = values
|
|
|
|
@property
|
|
def parents(self):
|
|
bits = self.code.split('-')[:-1]
|
|
while bits:
|
|
yield '-'.join(bits)
|
|
bits.pop()
|
|
|
|
def __cmp__(self, other):
|
|
return 0 if (self.continent_code == other.continent_code and
|
|
self.country_code == other.country_code and
|
|
self.subdivision_code == other.subdivision_code and
|
|
self.values == other.values) else 1
|
|
|
|
def __repr__(self):
|
|
return "'Geo {} {} {} {}'".format(self.continent_code,
|
|
self.country_code,
|
|
self.subdivision_code, self.values)
|
|
|
|
|
|
class _ValuesMixin(object):
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
super(_ValuesMixin, self).__init__(zone, name, data, source=source)
|
|
try:
|
|
values = data['values']
|
|
except KeyError:
|
|
try:
|
|
values = [data['value']]
|
|
except KeyError:
|
|
raise Exception('Invalid record {}, missing value(s)'
|
|
.format(self.fqdn))
|
|
self.values = sorted(self._process_values(values))
|
|
|
|
def changes(self, other, target):
|
|
if self.values != other.values:
|
|
return Update(self, other)
|
|
return super(_ValuesMixin, self).changes(other, target)
|
|
|
|
def _data(self):
|
|
ret = super(_ValuesMixin, self)._data()
|
|
if len(self.values) > 1:
|
|
ret['values'] = [getattr(v, 'data', v) for v in self.values]
|
|
else:
|
|
v = self.values[0]
|
|
ret['value'] = getattr(v, 'data', v)
|
|
return ret
|
|
|
|
def __repr__(self):
|
|
return '<{} {} {}, {}, {}>'.format(self.__class__.__name__,
|
|
self._type, self.ttl,
|
|
self.fqdn, self.values)
|
|
|
|
|
|
class _GeoMixin(_ValuesMixin):
|
|
'''
|
|
Adds GeoDNS support to a record.
|
|
|
|
Must be included before `Record`.
|
|
'''
|
|
|
|
# TODO: move away from "data" hash to strict params, it's kind of leaking
|
|
# the yaml implementation into here and then forcing it back out into
|
|
# non-yaml providers during input
|
|
def __init__(self, zone, name, data, *args, **kwargs):
|
|
super(_GeoMixin, self).__init__(zone, name, data, *args, **kwargs)
|
|
try:
|
|
self.geo = dict(data['geo'])
|
|
except KeyError:
|
|
self.geo = {}
|
|
for k, vs in self.geo.items():
|
|
vs = sorted(self._process_values(vs))
|
|
self.geo[k] = GeoValue(k, vs)
|
|
|
|
def _data(self):
|
|
ret = super(_GeoMixin, self)._data()
|
|
if self.geo:
|
|
geo = {}
|
|
for code, value in self.geo.items():
|
|
geo[code] = value.values
|
|
ret['geo'] = geo
|
|
return ret
|
|
|
|
def changes(self, other, target):
|
|
if target.SUPPORTS_GEO:
|
|
if self.geo != other.geo:
|
|
return Update(self, other)
|
|
return super(_GeoMixin, self).changes(other, target)
|
|
|
|
def __repr__(self):
|
|
if self.geo:
|
|
return '<{} {} {}, {}, {}, {}>'.format(self.__class__.__name__,
|
|
self._type, self.ttl,
|
|
self.fqdn, self.values,
|
|
self.geo)
|
|
return super(_GeoMixin, self).__repr__()
|
|
|
|
|
|
class ARecord(_GeoMixin, Record):
|
|
_type = 'A'
|
|
|
|
def _process_values(self, values):
|
|
for ip in values:
|
|
try:
|
|
IPv4Address(unicode(ip))
|
|
except Exception:
|
|
raise Exception('Invalid record {}, value {} not a valid ip'
|
|
.format(self.fqdn, ip))
|
|
return values
|
|
|
|
|
|
class AaaaRecord(_GeoMixin, Record):
|
|
_type = 'AAAA'
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for ip in values:
|
|
try:
|
|
IPv6Address(unicode(ip))
|
|
ret.append(ip.lower())
|
|
except Exception:
|
|
raise Exception('Invalid record {}, value {} not a valid ip'
|
|
.format(self.fqdn, ip))
|
|
return ret
|
|
|
|
|
|
class _ValueMixin(object):
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
super(_ValueMixin, self).__init__(zone, name, data, source=source)
|
|
try:
|
|
value = data['value']
|
|
except KeyError:
|
|
raise Exception('Invalid record {}, missing value'
|
|
.format(self.fqdn))
|
|
self.value = self._process_value(value)
|
|
|
|
def changes(self, other, target):
|
|
if self.value != other.value:
|
|
return Update(self, other)
|
|
return super(_ValueMixin, self).changes(other, target)
|
|
|
|
def _data(self):
|
|
ret = super(_ValueMixin, self)._data()
|
|
ret['value'] = getattr(self.value, 'data', self.value)
|
|
return ret
|
|
|
|
def __repr__(self):
|
|
return '<{} {} {}, {}, {}>'.format(self.__class__.__name__,
|
|
self._type, self.ttl,
|
|
self.fqdn, self.value)
|
|
|
|
|
|
class AliasRecord(_ValueMixin, Record):
|
|
_type = 'ALIAS'
|
|
|
|
def _process_value(self, value):
|
|
if not value.endswith('.'):
|
|
raise Exception('Invalid record {}, value ({}) missing trailing .'
|
|
.format(self.fqdn, value))
|
|
return value
|
|
|
|
|
|
class CnameRecord(_ValueMixin, Record):
|
|
_type = 'CNAME'
|
|
|
|
def _process_value(self, value):
|
|
if not value.endswith('.'):
|
|
raise Exception('Invalid record {}, value ({}) missing trailing .'
|
|
.format(self.fqdn, value))
|
|
return value.lower()
|
|
|
|
|
|
class MxValue(object):
|
|
|
|
def __init__(self, value):
|
|
# TODO: rename preference
|
|
self.priority = int(value['priority'])
|
|
# TODO: rename to exchange?
|
|
self.value = value['value'].lower()
|
|
|
|
@property
|
|
def data(self):
|
|
return {
|
|
'priority': self.priority,
|
|
'value': self.value,
|
|
}
|
|
|
|
def __cmp__(self, other):
|
|
if self.priority == other.priority:
|
|
return cmp(self.value, other.value)
|
|
return cmp(self.priority, other.priority)
|
|
|
|
def __repr__(self):
|
|
return "'{} {}'".format(self.priority, self.value)
|
|
|
|
|
|
class MxRecord(_ValuesMixin, Record):
|
|
_type = 'MX'
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for value in values:
|
|
try:
|
|
ret.append(MxValue(value))
|
|
except KeyError as e:
|
|
raise Exception('Invalid value in record {}, missing {}'
|
|
.format(self.fqdn, e.args[0]))
|
|
return ret
|
|
|
|
|
|
class NaptrValue(object):
|
|
|
|
def __init__(self, value):
|
|
self.order = int(value['order'])
|
|
self.preference = int(value['preference'])
|
|
self.flags = value['flags']
|
|
self.service = value['service']
|
|
self.regexp = value['regexp']
|
|
self.replacement = value['replacement']
|
|
|
|
@property
|
|
def data(self):
|
|
return {
|
|
'order': self.order,
|
|
'preference': self.preference,
|
|
'flags': self.flags,
|
|
'service': self.service,
|
|
'regexp': self.regexp,
|
|
'replacement': self.replacement,
|
|
}
|
|
|
|
def __cmp__(self, other):
|
|
if self.order != other.order:
|
|
return cmp(self.order, other.order)
|
|
elif self.preference != other.preference:
|
|
return cmp(self.preference, other.preference)
|
|
elif self.flags != other.flags:
|
|
return cmp(self.flags, other.flags)
|
|
elif self.service != other.service:
|
|
return cmp(self.service, other.service)
|
|
elif self.regexp != other.regexp:
|
|
return cmp(self.regexp, other.regexp)
|
|
return cmp(self.replacement, other.replacement)
|
|
|
|
def __repr__(self):
|
|
flags = self.flags if self.flags is not None else ''
|
|
service = self.service if self.service is not None else ''
|
|
regexp = self.regexp if self.regexp is not None else ''
|
|
return "'{} {} \"{}\" \"{}\" \"{}\" {}'" \
|
|
.format(self.order, self.preference, flags, service, regexp,
|
|
self.replacement)
|
|
|
|
|
|
class NaptrRecord(_ValuesMixin, Record):
|
|
_type = 'NAPTR'
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for value in values:
|
|
try:
|
|
ret.append(NaptrValue(value))
|
|
except KeyError as e:
|
|
raise Exception('Invalid value in record {}, missing {}'
|
|
.format(self.fqdn, e.args[0]))
|
|
return ret
|
|
|
|
|
|
class NsRecord(_ValuesMixin, Record):
|
|
_type = 'NS'
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for ns in values:
|
|
if not ns.endswith('.'):
|
|
raise Exception('Invalid record {}, value {} missing '
|
|
'trailing .'.format(self.fqdn, ns))
|
|
ret.append(ns.lower())
|
|
return ret
|
|
|
|
|
|
class PtrRecord(_ValueMixin, Record):
|
|
_type = 'PTR'
|
|
|
|
def _process_value(self, value):
|
|
if not value.endswith('.'):
|
|
raise Exception('Invalid record {}, value ({}) missing trailing .'
|
|
.format(self.fqdn, value))
|
|
return value.lower()
|
|
|
|
|
|
class SshfpValue(object):
|
|
|
|
def __init__(self, value):
|
|
self.algorithm = int(value['algorithm'])
|
|
self.fingerprint_type = int(value['fingerprint_type'])
|
|
self.fingerprint = value['fingerprint']
|
|
|
|
@property
|
|
def data(self):
|
|
return {
|
|
'algorithm': self.algorithm,
|
|
'fingerprint_type': self.fingerprint_type,
|
|
'fingerprint': self.fingerprint,
|
|
}
|
|
|
|
def __cmp__(self, other):
|
|
if self.algorithm != other.algorithm:
|
|
return cmp(self.algorithm, other.algorithm)
|
|
elif self.fingerprint_type != other.fingerprint_type:
|
|
return cmp(self.fingerprint_type, other.fingerprint_type)
|
|
return cmp(self.fingerprint, other.fingerprint)
|
|
|
|
def __repr__(self):
|
|
return "'{} {} {}'".format(self.algorithm, self.fingerprint_type,
|
|
self.fingerprint)
|
|
|
|
|
|
class SshfpRecord(_ValuesMixin, Record):
|
|
_type = 'SSHFP'
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for value in values:
|
|
try:
|
|
ret.append(SshfpValue(value))
|
|
except KeyError as e:
|
|
raise Exception('Invalid value in record {}, missing {}'
|
|
.format(self.fqdn, e.args[0]))
|
|
return ret
|
|
|
|
|
|
class SpfRecord(_ValuesMixin, Record):
|
|
_type = 'SPF'
|
|
|
|
def _process_values(self, values):
|
|
return values
|
|
|
|
|
|
class SrvValue(object):
|
|
|
|
def __init__(self, value):
|
|
self.priority = int(value['priority'])
|
|
self.weight = int(value['weight'])
|
|
self.port = int(value['port'])
|
|
self.target = value['target'].lower()
|
|
|
|
@property
|
|
def data(self):
|
|
return {
|
|
'priority': self.priority,
|
|
'weight': self.weight,
|
|
'port': self.port,
|
|
'target': self.target,
|
|
}
|
|
|
|
def __cmp__(self, other):
|
|
if self.priority != other.priority:
|
|
return cmp(self.priority, other.priority)
|
|
elif self.weight != other.weight:
|
|
return cmp(self.weight, other.weight)
|
|
elif self.port != other.port:
|
|
return cmp(self.port, other.port)
|
|
return cmp(self.target, other.target)
|
|
|
|
def __repr__(self):
|
|
return "'{} {} {} {}'".format(self.priority, self.weight, self.port,
|
|
self.target)
|
|
|
|
|
|
class SrvRecord(_ValuesMixin, Record):
|
|
_type = 'SRV'
|
|
_name_re = re.compile(r'^_[^\.]+\.[^\.]+')
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
if not self._name_re.match(name):
|
|
raise Exception('Invalid name {}.{}'.format(name, zone.name))
|
|
super(SrvRecord, self).__init__(zone, name, data, source)
|
|
|
|
def _process_values(self, values):
|
|
ret = []
|
|
for value in values:
|
|
try:
|
|
ret.append(SrvValue(value))
|
|
except KeyError as e:
|
|
raise Exception('Invalid value in record {}, missing {}'
|
|
.format(self.fqdn, e.args[0]))
|
|
return ret
|
|
|
|
|
|
class TxtRecord(_ValuesMixin, Record):
|
|
_type = 'TXT'
|
|
|
|
def _process_values(self, values):
|
|
for value in values:
|
|
if _unescaped_semicolon_re.search(value):
|
|
raise Exception('Invalid record {}, unescaped ;'
|
|
.format(self.fqdn))
|
|
return values
|