mirror of
				https://github.com/github/octodns.git
				synced 2024-05-11 05:55:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			475 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			475 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
#
 | 
						|
#
 | 
						|
#
 | 
						|
 | 
						|
from __future__ import absolute_import, division, print_function, \
 | 
						|
    unicode_literals
 | 
						|
 | 
						|
import re
 | 
						|
 | 
						|
from requests import Session
 | 
						|
from logging import getLogger
 | 
						|
 | 
						|
from ..record import Record
 | 
						|
from .base import BaseProvider
 | 
						|
 | 
						|
from collections import defaultdict
 | 
						|
 | 
						|
 | 
						|
def add_trailing_dot(value):
 | 
						|
    '''
 | 
						|
    Add trailing dots to values
 | 
						|
    '''
 | 
						|
    assert value, 'Missing value'
 | 
						|
    assert value[-1] != '.', 'Value already has trailing dot'
 | 
						|
    return value + '.'
 | 
						|
 | 
						|
 | 
						|
def remove_trailing_dot(value):
 | 
						|
    '''
 | 
						|
    Remove trailing dots from values
 | 
						|
    '''
 | 
						|
    assert value, 'Missing value'
 | 
						|
    assert value[-1] == '.', 'Value already missing trailing dot'
 | 
						|
    return value[:-1]
 | 
						|
 | 
						|
 | 
						|
class MythicBeastsUnauthorizedException(Exception):
 | 
						|
    def __init__(self, zone, *args):
 | 
						|
        self.zone = zone
 | 
						|
        self.message = 'Mythic Beasts unauthorized for zone: {}'.format(
 | 
						|
            self.zone
 | 
						|
        )
 | 
						|
 | 
						|
        super(MythicBeastsUnauthorizedException, self).__init__(
 | 
						|
            self.message, self.zone, *args)
 | 
						|
 | 
						|
 | 
						|
class MythicBeastsRecordException(Exception):
 | 
						|
    def __init__(self, zone, command, *args):
 | 
						|
        self.zone = zone
 | 
						|
        self.command = command
 | 
						|
        self.message = 'Mythic Beasts could not action command: {} {}'.format(
 | 
						|
            self.zone,
 | 
						|
            self.command,
 | 
						|
        )
 | 
						|
 | 
						|
        super(MythicBeastsRecordException, self).__init__(
 | 
						|
            self.message, self.zone, self.command, *args)
 | 
						|
 | 
						|
 | 
						|
class MythicBeastsProvider(BaseProvider):
 | 
						|
    '''
 | 
						|
    Mythic Beasts DNS API Provider
 | 
						|
 | 
						|
    Config settings:
 | 
						|
 | 
						|
    ---
 | 
						|
    providers:
 | 
						|
      config:
 | 
						|
      ...
 | 
						|
      mythicbeasts:
 | 
						|
        class: octodns.provider.mythicbeasts.MythicBeastsProvider
 | 
						|
          passwords:
 | 
						|
            my.domain.: 'password'
 | 
						|
 | 
						|
    zones:
 | 
						|
      my.domain.:
 | 
						|
        targets:
 | 
						|
          - mythic
 | 
						|
    '''
 | 
						|
 | 
						|
    RE_MX = re.compile(r'^(?P<preference>[0-9]+)\s+(?P<exchange>\S+)$',
 | 
						|
                       re.IGNORECASE)
 | 
						|
 | 
						|
    RE_SRV = re.compile(r'^(?P<priority>[0-9]+)\s+(?P<weight>[0-9]+)\s+'
 | 
						|
                        r'(?P<port>[0-9]+)\s+(?P<target>\S+)$',
 | 
						|
                        re.IGNORECASE)
 | 
						|
 | 
						|
    RE_SSHFP = re.compile(r'^(?P<algorithm>[0-9]+)\s+'
 | 
						|
                          r'(?P<fingerprint_type>[0-9]+)\s+'
 | 
						|
                          r'(?P<fingerprint>\S+)$',
 | 
						|
                          re.IGNORECASE)
 | 
						|
 | 
						|
    RE_CAA = re.compile(r'^(?P<flags>[0-9]+)\s+'
 | 
						|
                        r'(?P<tag>issue|issuewild|iodef)\s+'
 | 
						|
                        r'(?P<value>\S+)$',
 | 
						|
                        re.IGNORECASE)
 | 
						|
 | 
						|
    RE_POPLINE = re.compile(r'^(?P<name>\S+)\s+(?P<ttl>\d+)\s+'
 | 
						|
                            r'(?P<type>\S+)\s+(?P<value>.*)$',
 | 
						|
                            re.IGNORECASE)
 | 
						|
 | 
						|
    SUPPORTS_GEO = False
 | 
						|
    SUPPORTS_DYNAMIC = False
 | 
						|
    SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS',
 | 
						|
                    'SRV', 'SSHFP', 'CAA', 'TXT'))
 | 
						|
    BASE = 'https://dnsapi.mythic-beasts.com/'
 | 
						|
 | 
						|
    def __init__(self, identifier, passwords, *args, **kwargs):
 | 
						|
        self.log = getLogger('MythicBeastsProvider[{}]'.format(identifier))
 | 
						|
 | 
						|
        assert isinstance(passwords, dict), 'Passwords must be a dictionary'
 | 
						|
 | 
						|
        self.log.debug(
 | 
						|
            '__init__: id=%s, registered zones; %s',
 | 
						|
            identifier,
 | 
						|
            passwords.keys())
 | 
						|
        super(MythicBeastsProvider, self).__init__(identifier, *args, **kwargs)
 | 
						|
 | 
						|
        self._passwords = passwords
 | 
						|
        sess = Session()
 | 
						|
        self._sess = sess
 | 
						|
 | 
						|
    def _request(self, method, path, data=None):
 | 
						|
        self.log.debug('_request: method=%s, path=%s data=%s',
 | 
						|
                       method, path, data)
 | 
						|
 | 
						|
        resp = self._sess.request(method, path, data=data)
 | 
						|
        self.log.debug(
 | 
						|
            '_request:   status=%d data=%s',
 | 
						|
            resp.status_code,
 | 
						|
            resp.text[:20])
 | 
						|
 | 
						|
        if resp.status_code == 401:
 | 
						|
            raise MythicBeastsUnauthorizedException(data['domain'])
 | 
						|
 | 
						|
        if resp.status_code == 400:
 | 
						|
            raise MythicBeastsRecordException(
 | 
						|
                data['domain'],
 | 
						|
                data['command']
 | 
						|
            )
 | 
						|
        resp.raise_for_status()
 | 
						|
        return resp
 | 
						|
 | 
						|
    def _post(self, data=None):
 | 
						|
        return self._request('POST', self.BASE, data=data)
 | 
						|
 | 
						|
    def records(self, zone):
 | 
						|
        assert zone in self._passwords, 'Missing password for domain: {}' \
 | 
						|
            .format(remove_trailing_dot(zone))
 | 
						|
 | 
						|
        return self._post({
 | 
						|
            'domain': remove_trailing_dot(zone),
 | 
						|
            'password': self._passwords[zone],
 | 
						|
            'showall': 0,
 | 
						|
            'command': 'LIST',
 | 
						|
        })
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_single(_type, data):
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'value': data['raw_values'][0]['value'],
 | 
						|
            'ttl': data['raw_values'][0]['ttl']
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_multiple(_type, data):
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'values':
 | 
						|
                [raw_values['value'] for raw_values in data['raw_values']],
 | 
						|
            'ttl':
 | 
						|
                max([raw_values['ttl'] for raw_values in data['raw_values']]),
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_TXT(_type, data):
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'values':
 | 
						|
                [
 | 
						|
                    str(raw_values['value']).replace(';', '\\;')
 | 
						|
                    for raw_values in data['raw_values']
 | 
						|
                ],
 | 
						|
            'ttl':
 | 
						|
                max([raw_values['ttl'] for raw_values in data['raw_values']]),
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_MX(_type, data):
 | 
						|
        ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
 | 
						|
        values = []
 | 
						|
 | 
						|
        for raw_value in \
 | 
						|
                [raw_values['value'] for raw_values in data['raw_values']]:
 | 
						|
            match = MythicBeastsProvider.RE_MX.match(raw_value)
 | 
						|
 | 
						|
            assert match is not None, 'Unable to parse MX data'
 | 
						|
 | 
						|
            exchange = match.group('exchange')
 | 
						|
 | 
						|
            if not exchange.endswith('.'):
 | 
						|
                exchange = '{}.{}'.format(exchange, data['zone'])
 | 
						|
 | 
						|
            values.append({
 | 
						|
                'preference': match.group('preference'),
 | 
						|
                'exchange': exchange,
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'values': values,
 | 
						|
            'ttl': ttl,
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_CNAME(_type, data):
 | 
						|
        ttl = data['raw_values'][0]['ttl']
 | 
						|
        value = data['raw_values'][0]['value']
 | 
						|
        if not value.endswith('.'):
 | 
						|
            value = '{}.{}'.format(value, data['zone'])
 | 
						|
 | 
						|
        return MythicBeastsProvider._data_for_single(
 | 
						|
            _type,
 | 
						|
            {'raw_values': [
 | 
						|
                {'value': value, 'ttl': ttl}
 | 
						|
            ]})
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_ANAME(_type, data):
 | 
						|
        ttl = data['raw_values'][0]['ttl']
 | 
						|
        value = data['raw_values'][0]['value']
 | 
						|
        return MythicBeastsProvider._data_for_single(
 | 
						|
            'ALIAS',
 | 
						|
            {'raw_values': [
 | 
						|
                {'value': value, 'ttl': ttl}
 | 
						|
            ]})
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_SRV(_type, data):
 | 
						|
        ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
 | 
						|
        values = []
 | 
						|
 | 
						|
        for raw_value in \
 | 
						|
                [raw_values['value'] for raw_values in data['raw_values']]:
 | 
						|
 | 
						|
            match = MythicBeastsProvider.RE_SRV.match(raw_value)
 | 
						|
 | 
						|
            assert match is not None, 'Unable to parse SRV data'
 | 
						|
 | 
						|
            target = match.group('target')
 | 
						|
            if not target.endswith('.'):
 | 
						|
                target = '{}.{}'.format(target, data['zone'])
 | 
						|
 | 
						|
            values.append({
 | 
						|
                'priority': match.group('priority'),
 | 
						|
                'weight': match.group('weight'),
 | 
						|
                'port': match.group('port'),
 | 
						|
                'target': target,
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'values': values,
 | 
						|
            'ttl': ttl,
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_SSHFP(_type, data):
 | 
						|
        ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
 | 
						|
        values = []
 | 
						|
 | 
						|
        for raw_value in \
 | 
						|
                [raw_values['value'] for raw_values in data['raw_values']]:
 | 
						|
            match = MythicBeastsProvider.RE_SSHFP.match(raw_value)
 | 
						|
 | 
						|
            assert match is not None, 'Unable to parse SSHFP data'
 | 
						|
 | 
						|
            values.append({
 | 
						|
                'algorithm': match.group('algorithm'),
 | 
						|
                'fingerprint_type': match.group('fingerprint_type'),
 | 
						|
                'fingerprint': match.group('fingerprint'),
 | 
						|
            })
 | 
						|
 | 
						|
        return {
 | 
						|
            'type': _type,
 | 
						|
            'values': values,
 | 
						|
            'ttl': ttl,
 | 
						|
        }
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _data_for_CAA(_type, data):
 | 
						|
        ttl = data['raw_values'][0]['ttl']
 | 
						|
        raw_value = data['raw_values'][0]['value']
 | 
						|
 | 
						|
        match = MythicBeastsProvider.RE_CAA.match(raw_value)
 | 
						|
 | 
						|
        assert match is not None, 'Unable to parse CAA data'
 | 
						|
 | 
						|
        value = {
 | 
						|
            'flags': match.group('flags'),
 | 
						|
            'tag': match.group('tag'),
 | 
						|
            'value': match.group('value'),
 | 
						|
        }
 | 
						|
 | 
						|
        return MythicBeastsProvider._data_for_single(
 | 
						|
            'CAA',
 | 
						|
            {'raw_values': [{'value': value, 'ttl': ttl}]})
 | 
						|
 | 
						|
    _data_for_NS = _data_for_multiple
 | 
						|
    _data_for_A = _data_for_multiple
 | 
						|
    _data_for_AAAA = _data_for_multiple
 | 
						|
 | 
						|
    def populate(self, zone, target=False, lenient=False):
 | 
						|
        self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
 | 
						|
                       target, lenient)
 | 
						|
 | 
						|
        resp = self.records(zone.name)
 | 
						|
 | 
						|
        before = len(zone.records)
 | 
						|
        exists = False
 | 
						|
        data = defaultdict(lambda: defaultdict(lambda: {
 | 
						|
            'raw_values': [],
 | 
						|
            'name': None,
 | 
						|
            'zone': None,
 | 
						|
        }))
 | 
						|
 | 
						|
        exists = True
 | 
						|
        for line in resp.content.splitlines():
 | 
						|
            match = MythicBeastsProvider.RE_POPLINE.match(line)
 | 
						|
 | 
						|
            if match is None:
 | 
						|
                self.log.debug('failed to match line: %s', line)
 | 
						|
                continue
 | 
						|
 | 
						|
            if match.group(1) == '@':
 | 
						|
                _name = ''
 | 
						|
            else:
 | 
						|
                _name = match.group('name')
 | 
						|
 | 
						|
            _type = match.group('type')
 | 
						|
            _ttl = int(match.group('ttl'))
 | 
						|
            _value = match.group('value').strip()
 | 
						|
 | 
						|
            if hasattr(self, '_data_for_{}'.format(_type)):
 | 
						|
                if _name not in data[_type]:
 | 
						|
                    data[_type][_name] = {
 | 
						|
                        'raw_values': [{'value': _value, 'ttl': _ttl}],
 | 
						|
                        'name': _name,
 | 
						|
                        'zone': zone.name,
 | 
						|
                    }
 | 
						|
 | 
						|
                else:
 | 
						|
                    data[_type][_name].get('raw_values').append(
 | 
						|
                        {'value': _value, 'ttl': _ttl}
 | 
						|
                    )
 | 
						|
            else:
 | 
						|
                self.log.debug('skipping %s as not supported', _type)
 | 
						|
 | 
						|
        for _type in data:
 | 
						|
            for _name in data[_type]:
 | 
						|
                data_for = getattr(self, '_data_for_{}'.format(_type))
 | 
						|
 | 
						|
                record = Record.new(
 | 
						|
                    zone,
 | 
						|
                    _name,
 | 
						|
                    data_for(_type, data[_type][_name]),
 | 
						|
                    source=self
 | 
						|
                )
 | 
						|
                zone.add_record(record, lenient=lenient)
 | 
						|
 | 
						|
        self.log.debug('populate:   found %s records, exists=%s',
 | 
						|
                       len(zone.records) - before, exists)
 | 
						|
 | 
						|
        return exists
 | 
						|
 | 
						|
    def _compile_commands(self, action, record):
 | 
						|
        commands = []
 | 
						|
 | 
						|
        hostname = remove_trailing_dot(record.fqdn)
 | 
						|
        ttl = record.ttl
 | 
						|
        _type = record._type
 | 
						|
 | 
						|
        if _type == 'ALIAS':
 | 
						|
            _type = 'ANAME'
 | 
						|
 | 
						|
        if hasattr(record, 'values'):
 | 
						|
            values = record.values
 | 
						|
        else:
 | 
						|
            values = [record.value]
 | 
						|
 | 
						|
        base = '{} {} {} {}'.format(action, hostname, ttl, _type)
 | 
						|
 | 
						|
        # Unescape TXT records
 | 
						|
        if _type == 'TXT':
 | 
						|
            values = [value.replace('\\;', ';') for value in values]
 | 
						|
 | 
						|
        # Handle specific types or default
 | 
						|
        if _type == 'SSHFP':
 | 
						|
            data = values[0].data
 | 
						|
            commands.append('{} {} {} {}'.format(
 | 
						|
                base,
 | 
						|
                data['algorithm'],
 | 
						|
                data['fingerprint_type'],
 | 
						|
                data['fingerprint']
 | 
						|
            ))
 | 
						|
 | 
						|
        elif _type == 'SRV':
 | 
						|
            for value in values:
 | 
						|
                data = value.data
 | 
						|
                commands.append('{} {} {} {} {}'.format(
 | 
						|
                    base,
 | 
						|
                    data['priority'],
 | 
						|
                    data['weight'],
 | 
						|
                    data['port'],
 | 
						|
                    data['target']))
 | 
						|
 | 
						|
        elif _type == 'MX':
 | 
						|
            for value in values:
 | 
						|
                data = value.data
 | 
						|
                commands.append('{} {} {}'.format(
 | 
						|
                    base,
 | 
						|
                    data['preference'],
 | 
						|
                    data['exchange']))
 | 
						|
 | 
						|
        else:
 | 
						|
            if hasattr(self, '_data_for_{}'.format(_type)):
 | 
						|
                for value in values:
 | 
						|
                    commands.append('{} {}'.format(base, value))
 | 
						|
            else:
 | 
						|
                self.log.debug('skipping %s as not supported', _type)
 | 
						|
 | 
						|
        return commands
 | 
						|
 | 
						|
    def _apply_Create(self, change):
 | 
						|
        zone = change.new.zone
 | 
						|
        commands = self._compile_commands('ADD', change.new)
 | 
						|
 | 
						|
        for command in commands:
 | 
						|
            self._post({
 | 
						|
                'domain': remove_trailing_dot(zone.name),
 | 
						|
                'origin': '.',
 | 
						|
                'password': self._passwords[zone.name],
 | 
						|
                'command': command,
 | 
						|
            })
 | 
						|
        return True
 | 
						|
 | 
						|
    def _apply_Update(self, change):
 | 
						|
        self._apply_Delete(change)
 | 
						|
        self._apply_Create(change)
 | 
						|
 | 
						|
    def _apply_Delete(self, change):
 | 
						|
        zone = change.existing.zone
 | 
						|
        commands = self._compile_commands('DELETE', change.existing)
 | 
						|
 | 
						|
        for command in commands:
 | 
						|
            self._post({
 | 
						|
                'domain': remove_trailing_dot(zone.name),
 | 
						|
                'origin': '.',
 | 
						|
                'password': self._passwords[zone.name],
 | 
						|
                'command': command,
 | 
						|
            })
 | 
						|
        return True
 | 
						|
 | 
						|
    def _apply(self, plan):
 | 
						|
        desired = plan.desired
 | 
						|
        changes = plan.changes
 | 
						|
        self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
 | 
						|
                       len(changes))
 | 
						|
 | 
						|
        for change in changes:
 | 
						|
            class_name = change.__class__.__name__
 | 
						|
            getattr(self, '_apply_{}'.format(class_name))(change)
 |