| @ -0,0 +1,3 @@ | |||
| # These are supported funding model platforms | |||
| github: ross | |||
| @ -1,7 +1,11 @@ | |||
| include README.md | |||
| include CHANGELOG.md | |||
| include CODE_OF_CONDUCT.md | |||
| include CONTRIBUTING.md | |||
| include LICENSE | |||
| include docs/* | |||
| include octodns/* | |||
| include README.md | |||
| include requirements-dev.txt | |||
| include requirements.txt | |||
| include script/* | |||
| include tests/* | |||
| recursive-include docs *.png *.md | |||
| recursive-include tests *.json *.py *.txt *.yaml | |||
| recursive-include tests/zones * | |||
| @ -0,0 +1,6 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| @ -0,0 +1,61 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from logging import getLogger | |||
| from .base import BaseProcessor | |||
| class AcmeMangingProcessor(BaseProcessor): | |||
| log = getLogger('AcmeMangingProcessor') | |||
| def __init__(self, name): | |||
| ''' | |||
| processors: | |||
| acme: | |||
| class: octodns.processor.acme.AcmeMangingProcessor | |||
| ... | |||
| zones: | |||
| something.com.: | |||
| ... | |||
| processors: | |||
| - acme | |||
| ... | |||
| ''' | |||
| super(AcmeMangingProcessor, self).__init__(name) | |||
| self._owned = set() | |||
| def process_source_zone(self, desired, *args, **kwargs): | |||
| for record in desired.records: | |||
| if record._type == 'TXT' and \ | |||
| record.name.startswith('_acme-challenge'): | |||
| # We have a managed acme challenge record (owned by octoDNS) so | |||
| # we should mark it as such | |||
| record = record.copy() | |||
| record.values.append('*octoDNS*') | |||
| record.values.sort() | |||
| # This assumes we'll see things as sources before targets, | |||
| # which is the case... | |||
| self._owned.add(record) | |||
| desired.add_record(record, replace=True) | |||
| return desired | |||
| def process_target_zone(self, existing, *args, **kwargs): | |||
| for record in existing.records: | |||
| # Uses a startswith rather than == to ignore subdomain challenges, | |||
| # e.g. _acme-challenge.foo.domain.com when managing domain.com | |||
| if record._type == 'TXT' and \ | |||
| record.name.startswith('_acme-challenge') and \ | |||
| '*octoDNS*' not in record.values and \ | |||
| record not in self._owned: | |||
| self.log.info('_process: ignoring %s', record.fqdn) | |||
| existing.remove_record(record) | |||
| return existing | |||
| @ -0,0 +1,69 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| class BaseProcessor(object): | |||
| def __init__(self, name): | |||
| self.name = name | |||
| def process_source_zone(self, desired, sources): | |||
| ''' | |||
| Called after all sources have completed populate. Provides an | |||
| opportunity for the processor to modify the desired `Zone` that targets | |||
| will recieve. | |||
| - Will see `desired` after any modifications done by | |||
| `Provider._process_desired_zone` and processors configured to run | |||
| before this one. | |||
| - May modify `desired` directly. | |||
| - Must return `desired` which will normally be the `desired` param. | |||
| - Must not modify records directly, `record.copy` should be called, | |||
| the results of which can be modified, and then `Zone.add_record` may | |||
| be used with `replace=True`. | |||
| - May call `Zone.remove_record` to remove records from `desired`. | |||
| - Sources may be empty, as will be the case for aliased zones. | |||
| ''' | |||
| return desired | |||
| def process_target_zone(self, existing, target): | |||
| ''' | |||
| Called after a target has completed `populate`, before changes are | |||
| computed between `existing` and `desired`. This provides an opportunity | |||
| to modify the `existing` `Zone`. | |||
| - Will see `existing` after any modifrications done by processors | |||
| configured to run before this one. | |||
| - May modify `existing` directly. | |||
| - Must return `existing` which will normally be the `existing` param. | |||
| - Must not modify records directly, `record.copy` should be called, | |||
| the results of which can be modified, and then `Zone.add_record` may | |||
| be used with `replace=True`. | |||
| - May call `Zone.remove_record` to remove records from `existing`. | |||
| ''' | |||
| return existing | |||
| def process_plan(self, plan, sources, target): | |||
| ''' | |||
| Called after the planning phase has completed. Provides an opportunity | |||
| for the processors to modify the plan thus changing the actions that | |||
| will be displayed and potentially applied. | |||
| - `plan` may be None if no changes were detected, if so a `Plan` may | |||
| still be created and returned. | |||
| - May modify `plan.changes` directly or create a new `Plan`. | |||
| - Does not have to modify `plan.desired` and/or `plan.existing` to line | |||
| up with any modifications made to `plan.changes`. | |||
| - Should copy over `plan.exists`, `plan.update_pcent_threshold`, and | |||
| `plan.delete_pcent_threshold` when creating a new `Plan`. | |||
| - Must return a `Plan` which may be `plan` or can be a newly created | |||
| one `plan.desired` and `plan.existing` copied over as-is or modified. | |||
| ''' | |||
| # plan may be None if no changes were detected up until now, the | |||
| # process may still create a plan. | |||
| # sources may be empty, as will be the case for aliased zones | |||
| return plan | |||
| @ -0,0 +1,42 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from .base import BaseProcessor | |||
| class TypeAllowlistFilter(BaseProcessor): | |||
| def __init__(self, name, allowlist): | |||
| super(TypeAllowlistFilter, self).__init__(name) | |||
| self.allowlist = set(allowlist) | |||
| def _process(self, zone, *args, **kwargs): | |||
| for record in zone.records: | |||
| if record._type not in self.allowlist: | |||
| zone.remove_record(record) | |||
| return zone | |||
| process_source_zone = _process | |||
| process_target_zone = _process | |||
| class TypeRejectlistFilter(BaseProcessor): | |||
| def __init__(self, name, rejectlist): | |||
| super(TypeRejectlistFilter, self).__init__(name) | |||
| self.rejectlist = set(rejectlist) | |||
| def _process(self, zone, *args, **kwargs): | |||
| for record in zone.records: | |||
| if record._type in self.rejectlist: | |||
| zone.remove_record(record) | |||
| return zone | |||
| process_source_zone = _process | |||
| process_target_zone = _process | |||
| @ -0,0 +1,100 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from collections import defaultdict | |||
| from ..provider.plan import Plan | |||
| from ..record import Record | |||
| from .base import BaseProcessor | |||
| # Mark anything octoDNS is managing that way it can know it's safe to modify or | |||
| # delete. We'll take ownership of existing records that we're told to manage | |||
| # and thus "own" them going forward. | |||
| class OwnershipProcessor(BaseProcessor): | |||
| def __init__(self, name, txt_name='_owner', txt_value='*octodns*'): | |||
| super(OwnershipProcessor, self).__init__(name) | |||
| self.txt_name = txt_name | |||
| self.txt_value = txt_value | |||
| self._txt_values = [txt_value] | |||
| def process_source_zone(self, desired, *args, **kwargs): | |||
| for record in desired.records: | |||
| # Then create and add an ownership TXT for each of them | |||
| record_name = record.name.replace('*', '_wildcard') | |||
| if record.name: | |||
| name = '{}.{}.{}'.format(self.txt_name, record._type, | |||
| record_name) | |||
| else: | |||
| name = '{}.{}'.format(self.txt_name, record._type) | |||
| txt = Record.new(desired, name, { | |||
| 'type': 'TXT', | |||
| 'ttl': 60, | |||
| 'value': self.txt_value, | |||
| }) | |||
| desired.add_record(txt) | |||
| return desired | |||
| def _is_ownership(self, record): | |||
| return record._type == 'TXT' and \ | |||
| record.name.startswith(self.txt_name) \ | |||
| and record.values == self._txt_values | |||
| def process_plan(self, plan, *args, **kwargs): | |||
| if not plan: | |||
| # If we don't have any change there's nothing to do | |||
| return plan | |||
| # First find all the ownership info | |||
| owned = defaultdict(dict) | |||
| # We need to look for ownership in both the desired and existing | |||
| # states, many things will show up in both, but that's fine. | |||
| for record in list(plan.existing.records) + list(plan.desired.records): | |||
| if self._is_ownership(record): | |||
| pieces = record.name.split('.', 2) | |||
| if len(pieces) > 2: | |||
| _, _type, name = pieces | |||
| name = name.replace('_wildcard', '*') | |||
| else: | |||
| _type = pieces[1] | |||
| name = '' | |||
| owned[name][_type.upper()] = True | |||
| # Cases: | |||
| # - Configured in source | |||
| # - We'll fully CRU/manage it adding ownership TXT, | |||
| # thanks to process_source_zone, if needed | |||
| # - Not in source | |||
| # - Has an ownership TXT - delete it & the ownership TXT | |||
| # - Does not have an ownership TXT - don't delete it | |||
| # - Special records like octodns-meta | |||
| # - Should be left alone and should not have ownerthis TXTs | |||
| filtered_changes = [] | |||
| for change in plan.changes: | |||
| record = change.record | |||
| if not self._is_ownership(record) and \ | |||
| record._type not in owned[record.name] and \ | |||
| record.name != 'octodns-meta': | |||
| # It's not an ownership TXT, it's not owned, and it's not | |||
| # special we're going to ignore it | |||
| continue | |||
| # We own this record or owned it up until now so whatever the | |||
| # change is we should do | |||
| filtered_changes.append(change) | |||
| if plan.changes != filtered_changes: | |||
| return Plan(plan.existing, plan.desired, filtered_changes, | |||
| plan.exists, plan.update_pcent_threshold, | |||
| plan.delete_pcent_threshold) | |||
| return plan | |||
| @ -0,0 +1,379 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from collections import defaultdict | |||
| from requests import Session | |||
| import logging | |||
| from ..record import Record | |||
| from . import ProviderException | |||
| from .base import BaseProvider | |||
| class GandiClientException(ProviderException): | |||
| pass | |||
| class GandiClientBadRequest(GandiClientException): | |||
| def __init__(self, r): | |||
| super(GandiClientBadRequest, self).__init__(r.text) | |||
| class GandiClientUnauthorized(GandiClientException): | |||
| def __init__(self, r): | |||
| super(GandiClientUnauthorized, self).__init__(r.text) | |||
| class GandiClientForbidden(GandiClientException): | |||
| def __init__(self, r): | |||
| super(GandiClientForbidden, self).__init__(r.text) | |||
| class GandiClientNotFound(GandiClientException): | |||
| def __init__(self, r): | |||
| super(GandiClientNotFound, self).__init__(r.text) | |||
| class GandiClientUnknownDomainName(GandiClientException): | |||
| def __init__(self, msg): | |||
| super(GandiClientUnknownDomainName, self).__init__(msg) | |||
| class GandiClient(object): | |||
| def __init__(self, token): | |||
| session = Session() | |||
| session.headers.update({'Authorization': 'Apikey {}'.format(token)}) | |||
| self._session = session | |||
| self.endpoint = 'https://api.gandi.net/v5' | |||
| def _request(self, method, path, params={}, data=None): | |||
| url = '{}{}'.format(self.endpoint, path) | |||
| r = self._session.request(method, url, params=params, json=data) | |||
| if r.status_code == 400: | |||
| raise GandiClientBadRequest(r) | |||
| if r.status_code == 401: | |||
| raise GandiClientUnauthorized(r) | |||
| elif r.status_code == 403: | |||
| raise GandiClientForbidden(r) | |||
| elif r.status_code == 404: | |||
| raise GandiClientNotFound(r) | |||
| r.raise_for_status() | |||
| return r | |||
| def zone(self, zone_name): | |||
| return self._request('GET', '/livedns/domains/{}' | |||
| .format(zone_name)).json() | |||
| def zone_create(self, zone_name): | |||
| return self._request('POST', '/livedns/domains', data={ | |||
| 'fqdn': zone_name, | |||
| 'zone': {} | |||
| }).json() | |||
| def zone_records(self, zone_name): | |||
| records = self._request('GET', '/livedns/domains/{}/records' | |||
| .format(zone_name)).json() | |||
| for record in records: | |||
| if record['rrset_name'] == '@': | |||
| record['rrset_name'] = '' | |||
| # Change relative targets to absolute ones. | |||
| if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX', | |||
| 'NS', 'SRV']: | |||
| for i, value in enumerate(record['rrset_values']): | |||
| if not value.endswith('.'): | |||
| record['rrset_values'][i] = '{}.{}.'.format( | |||
| value, zone_name) | |||
| return records | |||
| def record_create(self, zone_name, data): | |||
| self._request('POST', '/livedns/domains/{}/records'.format(zone_name), | |||
| data=data) | |||
| def record_delete(self, zone_name, record_name, record_type): | |||
| self._request('DELETE', '/livedns/domains/{}/records/{}/{}' | |||
| .format(zone_name, record_name, record_type)) | |||
| class GandiProvider(BaseProvider): | |||
| ''' | |||
| Gandi provider using API v5. | |||
| gandi: | |||
| class: octodns.provider.gandi.GandiProvider | |||
| # Your API key (required) | |||
| token: XXXXXXXXXXXX | |||
| ''' | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS_DYNAMIC = False | |||
| SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', | |||
| 'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT'])) | |||
| def __init__(self, id, token, *args, **kwargs): | |||
| self.log = logging.getLogger('GandiProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, token=***', id) | |||
| super(GandiProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = GandiClient(token) | |||
| self._zone_records = {} | |||
| def _data_for_multiple(self, _type, records): | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'values': [v.replace(';', '\\;') for v in | |||
| records[0]['rrset_values']] if _type == 'TXT' else | |||
| records[0]['rrset_values'] | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| _data_for_TXT = _data_for_multiple | |||
| _data_for_SPF = _data_for_multiple | |||
| _data_for_NS = _data_for_multiple | |||
| def _data_for_CAA(self, _type, records): | |||
| values = [] | |||
| for record in records[0]['rrset_values']: | |||
| flags, tag, value = record.split(' ') | |||
| values.append({ | |||
| 'flags': flags, | |||
| 'tag': tag, | |||
| # Remove quotes around value. | |||
| 'value': value[1:-1], | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_single(self, _type, records): | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'value': records[0]['rrset_values'][0] | |||
| } | |||
| _data_for_ALIAS = _data_for_single | |||
| _data_for_CNAME = _data_for_single | |||
| _data_for_DNAME = _data_for_single | |||
| _data_for_PTR = _data_for_single | |||
| def _data_for_MX(self, _type, records): | |||
| values = [] | |||
| for record in records[0]['rrset_values']: | |||
| priority, server = record.split(' ') | |||
| values.append({ | |||
| 'preference': priority, | |||
| 'exchange': server | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_SRV(self, _type, records): | |||
| values = [] | |||
| for record in records[0]['rrset_values']: | |||
| priority, weight, port, target = record.split(' ', 3) | |||
| values.append({ | |||
| 'priority': priority, | |||
| 'weight': weight, | |||
| 'port': port, | |||
| 'target': target | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_SSHFP(self, _type, records): | |||
| values = [] | |||
| for record in records[0]['rrset_values']: | |||
| algorithm, fingerprint_type, fingerprint = record.split(' ', 2) | |||
| values.append({ | |||
| 'algorithm': algorithm, | |||
| 'fingerprint': fingerprint, | |||
| 'fingerprint_type': fingerprint_type | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['rrset_ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def zone_records(self, zone): | |||
| if zone.name not in self._zone_records: | |||
| try: | |||
| self._zone_records[zone.name] = \ | |||
| self._client.zone_records(zone.name[:-1]) | |||
| except GandiClientNotFound: | |||
| return [] | |||
| return self._zone_records[zone.name] | |||
| def populate(self, zone, target=False, lenient=False): | |||
| self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, | |||
| target, lenient) | |||
| values = defaultdict(lambda: defaultdict(list)) | |||
| for record in self.zone_records(zone): | |||
| _type = record['rrset_type'] | |||
| if _type not in self.SUPPORTS: | |||
| continue | |||
| values[record['rrset_name']][record['rrset_type']].append(record) | |||
| before = len(zone.records) | |||
| for name, types in values.items(): | |||
| for _type, records in types.items(): | |||
| data_for = getattr(self, '_data_for_{}'.format(_type)) | |||
| record = Record.new(zone, name, data_for(_type, records), | |||
| source=self, lenient=lenient) | |||
| zone.add_record(record, lenient=lenient) | |||
| exists = zone.name in self._zone_records | |||
| self.log.info('populate: found %s records, exists=%s', | |||
| len(zone.records) - before, exists) | |||
| return exists | |||
| def _record_name(self, name): | |||
| return name if name else '@' | |||
| def _params_for_multiple(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': [v.replace('\\;', ';') for v in | |||
| record.values] if record._type == 'TXT' | |||
| else record.values | |||
| } | |||
| _params_for_A = _params_for_multiple | |||
| _params_for_AAAA = _params_for_multiple | |||
| _params_for_NS = _params_for_multiple | |||
| _params_for_TXT = _params_for_multiple | |||
| _params_for_SPF = _params_for_multiple | |||
| def _params_for_CAA(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': ['{} {} "{}"'.format(v.flags, v.tag, v.value) | |||
| for v in record.values] | |||
| } | |||
| def _params_for_single(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': [record.value] | |||
| } | |||
| _params_for_ALIAS = _params_for_single | |||
| _params_for_CNAME = _params_for_single | |||
| _params_for_DNAME = _params_for_single | |||
| _params_for_PTR = _params_for_single | |||
| def _params_for_MX(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': ['{} {}'.format(v.preference, v.exchange) | |||
| for v in record.values] | |||
| } | |||
| def _params_for_SRV(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': ['{} {} {} {}'.format(v.priority, v.weight, v.port, | |||
| v.target) for v in record.values] | |||
| } | |||
| def _params_for_SSHFP(self, record): | |||
| return { | |||
| 'rrset_name': self._record_name(record.name), | |||
| 'rrset_ttl': record.ttl, | |||
| 'rrset_type': record._type, | |||
| 'rrset_values': ['{} {} {}'.format(v.algorithm, v.fingerprint_type, | |||
| v.fingerprint) for v in record.values] | |||
| } | |||
| def _apply_create(self, change): | |||
| new = change.new | |||
| data = getattr(self, '_params_for_{}'.format(new._type))(new) | |||
| self._client.record_create(new.zone.name[:-1], data) | |||
| def _apply_update(self, change): | |||
| self._apply_delete(change) | |||
| self._apply_create(change) | |||
| def _apply_delete(self, change): | |||
| existing = change.existing | |||
| zone = existing.zone | |||
| self._client.record_delete(zone.name[:-1], | |||
| self._record_name(existing.name), | |||
| existing._type) | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| zone = desired.name[:-1] | |||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| try: | |||
| self._client.zone(zone) | |||
| except GandiClientNotFound: | |||
| self.log.info('_apply: no existing zone, trying to create it') | |||
| try: | |||
| self._client.zone_create(zone) | |||
| self.log.info('_apply: zone has been successfully created') | |||
| except GandiClientNotFound: | |||
| # We suppress existing exception before raising | |||
| # GandiClientUnknownDomainName. | |||
| e = GandiClientUnknownDomainName('This domain is not ' | |||
| 'registered at Gandi. ' | |||
| 'Please register or ' | |||
| 'transfer it here ' | |||
| 'to be able to manage its ' | |||
| 'DNS zone.') | |||
| e.__cause__ = None | |||
| raise e | |||
| # Force records deletion to be done before creation in order to avoid | |||
| # "CNAME record must be the only record" error when an existing CNAME | |||
| # record is replaced by an A/AAAA record. | |||
| changes.reverse() | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| getattr(self, '_apply_{}'.format(class_name.lower()))(change) | |||
| # Clear out the cache if any | |||
| self._zone_records.pop(desired.name, None) | |||
| @ -0,0 +1,624 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import ( | |||
| absolute_import, | |||
| division, | |||
| print_function, | |||
| unicode_literals, | |||
| ) | |||
| from collections import defaultdict | |||
| from requests import Session | |||
| import http | |||
| import logging | |||
| import urllib.parse | |||
| from ..record import GeoCodes | |||
| from ..record import Record | |||
| from . import ProviderException | |||
| from .base import BaseProvider | |||
| class GCoreClientException(ProviderException): | |||
| def __init__(self, r): | |||
| super(GCoreClientException, self).__init__(r.text) | |||
| class GCoreClientBadRequest(GCoreClientException): | |||
| def __init__(self, r): | |||
| super(GCoreClientBadRequest, self).__init__(r) | |||
| class GCoreClientNotFound(GCoreClientException): | |||
| def __init__(self, r): | |||
| super(GCoreClientNotFound, self).__init__(r) | |||
| class GCoreClient(object): | |||
| ROOT_ZONES = "zones" | |||
| def __init__( | |||
| self, | |||
| log, | |||
| api_url, | |||
| auth_url, | |||
| token=None, | |||
| token_type=None, | |||
| login=None, | |||
| password=None, | |||
| ): | |||
| self.log = log | |||
| self._session = Session() | |||
| self._api_url = api_url | |||
| if token is not None and token_type is not None: | |||
| self._session.headers.update( | |||
| {"Authorization": "{} {}".format(token_type, token)} | |||
| ) | |||
| elif login is not None and password is not None: | |||
| token = self._auth(auth_url, login, password) | |||
| self._session.headers.update( | |||
| {"Authorization": "Bearer {}".format(token)} | |||
| ) | |||
| else: | |||
| raise ValueError("either token or login & password must be set") | |||
| def _auth(self, url, login, password): | |||
| # well, can't use _request, since API returns 400 if credentials | |||
| # invalid which will be logged, but we don't want do this | |||
| r = self._session.request( | |||
| "POST", | |||
| self._build_url(url, "auth", "jwt", "login"), | |||
| json={"username": login, "password": password}, | |||
| ) | |||
| r.raise_for_status() | |||
| return r.json()["access"] | |||
| def _request(self, method, url, params=None, data=None): | |||
| r = self._session.request( | |||
| method, url, params=params, json=data, timeout=30.0 | |||
| ) | |||
| if r.status_code == http.HTTPStatus.BAD_REQUEST: | |||
| self.log.error( | |||
| "bad request %r has been sent to %r: %s", data, url, r.text | |||
| ) | |||
| raise GCoreClientBadRequest(r) | |||
| elif r.status_code == http.HTTPStatus.NOT_FOUND: | |||
| self.log.error("resource %r not found: %s", url, r.text) | |||
| raise GCoreClientNotFound(r) | |||
| elif r.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR: | |||
| self.log.error("server error no %r to %r: %s", data, url, r.text) | |||
| raise GCoreClientException(r) | |||
| r.raise_for_status() | |||
| return r | |||
| def zone(self, zone_name): | |||
| return self._request( | |||
| "GET", self._build_url(self._api_url, self.ROOT_ZONES, zone_name) | |||
| ).json() | |||
| def zone_create(self, zone_name): | |||
| return self._request( | |||
| "POST", | |||
| self._build_url(self._api_url, self.ROOT_ZONES), | |||
| data={"name": zone_name}, | |||
| ).json() | |||
| def zone_records(self, zone_name): | |||
| rrsets = self._request( | |||
| "GET", | |||
| "{}".format( | |||
| self._build_url( | |||
| self._api_url, self.ROOT_ZONES, zone_name, "rrsets" | |||
| ) | |||
| ), | |||
| params={"all": "true"}, | |||
| ).json() | |||
| records = rrsets["rrsets"] | |||
| return records | |||
| def record_create(self, zone_name, rrset_name, type_, data): | |||
| self._request( | |||
| "POST", self._rrset_url(zone_name, rrset_name, type_), data=data | |||
| ) | |||
| def record_update(self, zone_name, rrset_name, type_, data): | |||
| self._request( | |||
| "PUT", self._rrset_url(zone_name, rrset_name, type_), data=data | |||
| ) | |||
| def record_delete(self, zone_name, rrset_name, type_): | |||
| self._request("DELETE", self._rrset_url(zone_name, rrset_name, type_)) | |||
| def _rrset_url(self, zone_name, rrset_name, type_): | |||
| return self._build_url( | |||
| self._api_url, self.ROOT_ZONES, zone_name, rrset_name, type_ | |||
| ) | |||
| @staticmethod | |||
| def _build_url(base, *items): | |||
| for i in items: | |||
| base = base.strip("/") + "/" | |||
| base = urllib.parse.urljoin(base, i) | |||
| return base | |||
| class GCoreProvider(BaseProvider): | |||
| """ | |||
| GCore provider using API v2. | |||
| gcore: | |||
| class: octodns.provider.gcore.GCoreProvider | |||
| # Your API key | |||
| token: XXXXXXXXXXXX | |||
| # token_type: APIKey | |||
| # or login + password | |||
| login: XXXXXXXXXXXX | |||
| password: XXXXXXXXXXXX | |||
| # auth_url: https://api.gcdn.co | |||
| # url: https://dnsapi.gcorelabs.com/v2 | |||
| # records_per_response: 1 | |||
| """ | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS_DYNAMIC = True | |||
| SUPPORTS = set(("A", "AAAA", "NS", "MX", "TXT", "SRV", "CNAME", "PTR")) | |||
| def __init__(self, id, *args, **kwargs): | |||
| token = kwargs.pop("token", None) | |||
| token_type = kwargs.pop("token_type", "APIKey") | |||
| login = kwargs.pop("login", None) | |||
| password = kwargs.pop("password", None) | |||
| api_url = kwargs.pop("url", "https://dnsapi.gcorelabs.com/v2") | |||
| auth_url = kwargs.pop("auth_url", "https://api.gcdn.co") | |||
| self.records_per_response = kwargs.pop("records_per_response", 1) | |||
| self.log = logging.getLogger("GCoreProvider[{}]".format(id)) | |||
| self.log.debug("__init__: id=%s", id) | |||
| super(GCoreProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = GCoreClient( | |||
| self.log, | |||
| api_url, | |||
| auth_url, | |||
| token=token, | |||
| token_type=token_type, | |||
| login=login, | |||
| password=password, | |||
| ) | |||
| def _add_dot_if_need(self, value): | |||
| return "{}.".format(value) if not value.endswith(".") else value | |||
| def _build_pools(self, record, default_pool_name, value_transform_fn): | |||
| defaults = [] | |||
| geo_sets, pool_idx = dict(), 0 | |||
| pools = defaultdict(lambda: {"values": []}) | |||
| for rr in record["resource_records"]: | |||
| meta = rr.get("meta", {}) or {} | |||
| value = {"value": value_transform_fn(rr["content"][0])} | |||
| countries = meta.get("countries", []) or [] | |||
| continents = meta.get("continents", []) or [] | |||
| if meta.get("default", False): | |||
| pools[default_pool_name]["values"].append(value) | |||
| defaults.append(value["value"]) | |||
| continue | |||
| # defaults is false or missing and no conties or continents | |||
| elif len(continents) == 0 and len(countries) == 0: | |||
| defaults.append(value["value"]) | |||
| continue | |||
| # RR with the same set of countries and continents are | |||
| # combined in single pool | |||
| geo_set = frozenset( | |||
| [GeoCodes.country_to_code(cc.upper()) for cc in countries] | |||
| ) | frozenset(cc.upper() for cc in continents) | |||
| if geo_set not in geo_sets: | |||
| geo_sets[geo_set] = "pool-{}".format(pool_idx) | |||
| pool_idx += 1 | |||
| pools[geo_sets[geo_set]]["values"].append(value) | |||
| return pools, geo_sets, defaults | |||
| def _build_rules(self, pools, geo_sets): | |||
| rules = [] | |||
| for name, _ in pools.items(): | |||
| rule = {"pool": name} | |||
| geo_set = next( | |||
| ( | |||
| geo_set | |||
| for geo_set, pool_name in geo_sets.items() | |||
| if pool_name == name | |||
| ), | |||
| {}, | |||
| ) | |||
| if len(geo_set) > 0: | |||
| rule["geos"] = list(geo_set) | |||
| rules.append(rule) | |||
| return sorted(rules, key=lambda x: x["pool"]) | |||
| def _data_for_dynamic(self, record, value_transform_fn=lambda x: x): | |||
| default_pool = "other" | |||
| pools, geo_sets, defaults = self._build_pools( | |||
| record, default_pool, value_transform_fn | |||
| ) | |||
| if len(pools) == 0: | |||
| raise RuntimeError( | |||
| "filter is enabled, but no pools where built for {}".format( | |||
| record | |||
| ) | |||
| ) | |||
| # defaults can't be empty, so use first pool values | |||
| if len(defaults) == 0: | |||
| defaults = [ | |||
| value_transform_fn(v["value"]) | |||
| for v in next(iter(pools.values()))["values"] | |||
| ] | |||
| # if at least one default RR was found then setup fallback for | |||
| # other pools to default | |||
| if default_pool in pools: | |||
| for pool_name, pool in pools.items(): | |||
| if pool_name == default_pool: | |||
| continue | |||
| pool["fallback"] = default_pool | |||
| rules = self._build_rules(pools, geo_sets) | |||
| return pools, rules, defaults | |||
| def _data_for_single(self, _type, record): | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "value": self._add_dot_if_need( | |||
| record["resource_records"][0]["content"][0] | |||
| ), | |||
| } | |||
| _data_for_PTR = _data_for_single | |||
| def _data_for_CNAME(self, _type, record): | |||
| if record.get("filters") is None: | |||
| return self._data_for_single(_type, record) | |||
| pools, rules, defaults = self._data_for_dynamic( | |||
| record, self._add_dot_if_need | |||
| ) | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "dynamic": {"pools": pools, "rules": rules}, | |||
| "value": self._add_dot_if_need(defaults[0]), | |||
| } | |||
| def _data_for_multiple(self, _type, record): | |||
| extra = dict() | |||
| if record.get("filters") is not None: | |||
| pools, rules, defaults = self._data_for_dynamic(record) | |||
| extra = { | |||
| "dynamic": {"pools": pools, "rules": rules}, | |||
| "values": defaults, | |||
| } | |||
| else: | |||
| extra = { | |||
| "values": [ | |||
| rr_value | |||
| for resource_record in record["resource_records"] | |||
| for rr_value in resource_record["content"] | |||
| ] | |||
| } | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| **extra, | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| def _data_for_TXT(self, _type, record): | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "values": [ | |||
| rr_value.replace(";", "\\;") | |||
| for resource_record in record["resource_records"] | |||
| for rr_value in resource_record["content"] | |||
| ], | |||
| } | |||
| def _data_for_MX(self, _type, record): | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "values": [ | |||
| dict( | |||
| preference=preference, | |||
| exchange=self._add_dot_if_need(exchange), | |||
| ) | |||
| for preference, exchange in map( | |||
| lambda x: x["content"], record["resource_records"] | |||
| ) | |||
| ], | |||
| } | |||
| def _data_for_NS(self, _type, record): | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "values": [ | |||
| self._add_dot_if_need(rr_value) | |||
| for resource_record in record["resource_records"] | |||
| for rr_value in resource_record["content"] | |||
| ], | |||
| } | |||
| def _data_for_SRV(self, _type, record): | |||
| return { | |||
| "ttl": record["ttl"], | |||
| "type": _type, | |||
| "values": [ | |||
| dict( | |||
| priority=priority, | |||
| weight=weight, | |||
| port=port, | |||
| target=self._add_dot_if_need(target), | |||
| ) | |||
| for priority, weight, port, target in map( | |||
| lambda x: x["content"], record["resource_records"] | |||
| ) | |||
| ], | |||
| } | |||
| def zone_records(self, zone): | |||
| try: | |||
| return self._client.zone_records(zone.name[:-1]), True | |||
| except GCoreClientNotFound: | |||
| return [], False | |||
| def populate(self, zone, target=False, lenient=False): | |||
| self.log.debug( | |||
| "populate: name=%s, target=%s, lenient=%s", | |||
| zone.name, | |||
| target, | |||
| lenient, | |||
| ) | |||
| values = defaultdict(defaultdict) | |||
| records, exists = self.zone_records(zone) | |||
| for record in records: | |||
| _type = record["type"].upper() | |||
| if _type not in self.SUPPORTS: | |||
| continue | |||
| if self._should_ignore(record): | |||
| continue | |||
| rr_name = zone.hostname_from_fqdn(record["name"]) | |||
| values[rr_name][_type] = record | |||
| before = len(zone.records) | |||
| for name, types in values.items(): | |||
| for _type, record in types.items(): | |||
| data_for = getattr(self, "_data_for_{}".format(_type)) | |||
| record = Record.new( | |||
| zone, | |||
| name, | |||
| data_for(_type, record), | |||
| 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 _should_ignore(self, record): | |||
| name = record.get("name", "name-not-defined") | |||
| if record.get("filters") is None: | |||
| return False | |||
| want_filters = 3 | |||
| filters = record.get("filters", []) | |||
| if len(filters) != want_filters: | |||
| self.log.info( | |||
| "ignore %s has filters and their count is not %d", | |||
| name, | |||
| want_filters, | |||
| ) | |||
| return True | |||
| types = [v.get("type") for v in filters] | |||
| for i, want_type in enumerate(["geodns", "default", "first_n"]): | |||
| if types[i] != want_type: | |||
| self.log.info( | |||
| "ignore %s, filters.%d.type is %s, want %s", | |||
| name, | |||
| i, | |||
| types[i], | |||
| want_type, | |||
| ) | |||
| return True | |||
| limits = [filters[i].get("limit", 1) for i in [1, 2]] | |||
| if limits[0] != limits[1]: | |||
| self.log.info( | |||
| "ignore %s, filters.1.limit (%d) != filters.2.limit (%d)", | |||
| name, | |||
| limits[0], | |||
| limits[1], | |||
| ) | |||
| return True | |||
| return False | |||
| def _params_for_dymanic(self, record): | |||
| records = [] | |||
| default_pool_found = False | |||
| default_values = set( | |||
| record.values if hasattr(record, "values") else [record.value] | |||
| ) | |||
| for rule in record.dynamic.rules: | |||
| meta = dict() | |||
| # build meta tags if geos information present | |||
| if len(rule.data.get("geos", [])) > 0: | |||
| for geo_code in rule.data["geos"]: | |||
| geo = GeoCodes.parse(geo_code) | |||
| country = geo["country_code"] | |||
| continent = geo["continent_code"] | |||
| if country is not None: | |||
| meta.setdefault("countries", []).append(country) | |||
| else: | |||
| meta.setdefault("continents", []).append(continent) | |||
| else: | |||
| meta["default"] = True | |||
| pool_values = set() | |||
| pool_name = rule.data["pool"] | |||
| for value in record.dynamic.pools[pool_name].data["values"]: | |||
| v = value["value"] | |||
| records.append({"content": [v], "meta": meta}) | |||
| pool_values.add(v) | |||
| default_pool_found |= default_values == pool_values | |||
| # if default values doesn't match any pool values, then just add this | |||
| # values with no any meta | |||
| if not default_pool_found: | |||
| for value in default_values: | |||
| records.append({"content": [value]}) | |||
| return records | |||
| def _params_for_single(self, record): | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": [{"content": [record.value]}], | |||
| } | |||
| _params_for_PTR = _params_for_single | |||
| def _params_for_CNAME(self, record): | |||
| if not record.dynamic: | |||
| return self._params_for_single(record) | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": self._params_for_dymanic(record), | |||
| "filters": [ | |||
| {"type": "geodns"}, | |||
| { | |||
| "type": "default", | |||
| "limit": self.records_per_response, | |||
| "strict": False, | |||
| }, | |||
| {"type": "first_n", "limit": self.records_per_response}, | |||
| ], | |||
| } | |||
| def _params_for_multiple(self, record): | |||
| extra = dict() | |||
| if record.dynamic: | |||
| extra["resource_records"] = self._params_for_dymanic(record) | |||
| extra["filters"] = [ | |||
| {"type": "geodns"}, | |||
| { | |||
| "type": "default", | |||
| "limit": self.records_per_response, | |||
| "strict": False, | |||
| }, | |||
| {"type": "first_n", "limit": self.records_per_response}, | |||
| ] | |||
| else: | |||
| extra["resource_records"] = [ | |||
| {"content": [value]} for value in record.values | |||
| ] | |||
| return { | |||
| "ttl": record.ttl, | |||
| **extra, | |||
| } | |||
| _params_for_A = _params_for_multiple | |||
| _params_for_AAAA = _params_for_multiple | |||
| def _params_for_NS(self, record): | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": [ | |||
| {"content": [value]} for value in record.values | |||
| ], | |||
| } | |||
| def _params_for_TXT(self, record): | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": [ | |||
| {"content": [value.replace("\\;", ";")]} | |||
| for value in record.values | |||
| ], | |||
| } | |||
| def _params_for_MX(self, record): | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": [ | |||
| {"content": [rec.preference, rec.exchange]} | |||
| for rec in record.values | |||
| ], | |||
| } | |||
| def _params_for_SRV(self, record): | |||
| return { | |||
| "ttl": record.ttl, | |||
| "resource_records": [ | |||
| {"content": [rec.priority, rec.weight, rec.port, rec.target]} | |||
| for rec in record.values | |||
| ], | |||
| } | |||
| def _apply_create(self, change): | |||
| self.log.info("creating: %s", change) | |||
| new = change.new | |||
| data = getattr(self, "_params_for_{}".format(new._type))(new) | |||
| self._client.record_create( | |||
| new.zone.name[:-1], new.fqdn, new._type, data | |||
| ) | |||
| def _apply_update(self, change): | |||
| self.log.info("updating: %s", change) | |||
| new = change.new | |||
| data = getattr(self, "_params_for_{}".format(new._type))(new) | |||
| self._client.record_update( | |||
| new.zone.name[:-1], new.fqdn, new._type, data | |||
| ) | |||
| def _apply_delete(self, change): | |||
| self.log.info("deleting: %s", change) | |||
| existing = change.existing | |||
| self._client.record_delete( | |||
| existing.zone.name[:-1], existing.fqdn, existing._type | |||
| ) | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| zone = desired.name[:-1] | |||
| self.log.debug( | |||
| "_apply: zone=%s, len(changes)=%d", desired.name, len(changes) | |||
| ) | |||
| try: | |||
| self._client.zone(zone) | |||
| except GCoreClientNotFound: | |||
| self.log.info("_apply: no existing zone, trying to create it") | |||
| self._client.zone_create(zone) | |||
| self.log.info("_apply: zone has been successfully created") | |||
| changes.reverse() | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| getattr(self, "_apply_{}".format(class_name.lower()))(change) | |||
| @ -0,0 +1,340 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from collections import defaultdict | |||
| from requests import Session | |||
| import logging | |||
| from ..record import Record | |||
| from . import ProviderException | |||
| from .base import BaseProvider | |||
| class HetznerClientException(ProviderException): | |||
| pass | |||
| class HetznerClientNotFound(HetznerClientException): | |||
| def __init__(self): | |||
| super(HetznerClientNotFound, self).__init__('Not Found') | |||
| class HetznerClientUnauthorized(HetznerClientException): | |||
| def __init__(self): | |||
| super(HetznerClientUnauthorized, self).__init__('Unauthorized') | |||
| class HetznerClient(object): | |||
| BASE_URL = 'https://dns.hetzner.com/api/v1' | |||
| def __init__(self, token): | |||
| session = Session() | |||
| session.headers.update({'Auth-API-Token': token}) | |||
| self._session = session | |||
| def _do(self, method, path, params=None, data=None): | |||
| url = '{}{}'.format(self.BASE_URL, path) | |||
| response = self._session.request(method, url, params=params, json=data) | |||
| if response.status_code == 401: | |||
| raise HetznerClientUnauthorized() | |||
| if response.status_code == 404: | |||
| raise HetznerClientNotFound() | |||
| response.raise_for_status() | |||
| return response | |||
| def _do_json(self, method, path, params=None, data=None): | |||
| return self._do(method, path, params, data).json() | |||
| def zone_get(self, name): | |||
| params = {'name': name} | |||
| return self._do_json('GET', '/zones', params)['zones'][0] | |||
| def zone_create(self, name, ttl=None): | |||
| data = {'name': name, 'ttl': ttl} | |||
| return self._do_json('POST', '/zones', data=data)['zone'] | |||
| def zone_records_get(self, zone_id): | |||
| params = {'zone_id': zone_id} | |||
| records = self._do_json('GET', '/records', params=params)['records'] | |||
| for record in records: | |||
| if record['name'] == '@': | |||
| record['name'] = '' | |||
| return records | |||
| def zone_record_create(self, zone_id, name, _type, value, ttl=None): | |||
| data = {'name': name or '@', 'ttl': ttl, 'type': _type, 'value': value, | |||
| 'zone_id': zone_id} | |||
| self._do('POST', '/records', data=data) | |||
| def zone_record_delete(self, zone_id, record_id): | |||
| self._do('DELETE', '/records/{}'.format(record_id)) | |||
| class HetznerProvider(BaseProvider): | |||
| ''' | |||
| Hetzner DNS provider using API v1 | |||
| hetzner: | |||
| class: octodns.provider.hetzner.HetznerProvider | |||
| # Your Hetzner API token (required) | |||
| token: foo | |||
| ''' | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS_DYNAMIC = False | |||
| SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV', 'TXT')) | |||
| def __init__(self, id, token, *args, **kwargs): | |||
| self.log = logging.getLogger('HetznerProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, token=***', id) | |||
| super(HetznerProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = HetznerClient(token) | |||
| self._zone_records = {} | |||
| self._zone_metadata = {} | |||
| self._zone_name_to_id = {} | |||
| def _append_dot(self, value): | |||
| if value == '@' or value[-1] == '.': | |||
| return value | |||
| return '{}.'.format(value) | |||
| def zone_metadata(self, zone_id=None, zone_name=None): | |||
| if zone_name is not None: | |||
| if zone_name in self._zone_name_to_id: | |||
| zone_id = self._zone_name_to_id[zone_name] | |||
| else: | |||
| zone = self._client.zone_get(name=zone_name[:-1]) | |||
| zone_id = zone['id'] | |||
| self._zone_name_to_id[zone_name] = zone_id | |||
| self._zone_metadata[zone_id] = zone | |||
| return self._zone_metadata[zone_id] | |||
| def _record_ttl(self, record): | |||
| default_ttl = self.zone_metadata(zone_id=record['zone_id'])['ttl'] | |||
| return record['ttl'] if 'ttl' in record else default_ttl | |||
| def _data_for_multiple(self, _type, records): | |||
| values = [record['value'].replace(';', '\\;') for record in records] | |||
| return { | |||
| 'ttl': self._record_ttl(records[0]), | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| def _data_for_CAA(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| value_without_spaces = record['value'].replace(' ', '') | |||
| flags = value_without_spaces[0] | |||
| tag = value_without_spaces[1:].split('"')[0] | |||
| value = record['value'].split('"')[1] | |||
| values.append({ | |||
| 'flags': int(flags), | |||
| 'tag': tag, | |||
| 'value': value, | |||
| }) | |||
| return { | |||
| 'ttl': self._record_ttl(records[0]), | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_CNAME(self, _type, records): | |||
| record = records[0] | |||
| return { | |||
| 'ttl': self._record_ttl(record), | |||
| 'type': _type, | |||
| 'value': self._append_dot(record['value']) | |||
| } | |||
| def _data_for_MX(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| value_stripped_split = record['value'].strip().split(' ') | |||
| preference = value_stripped_split[0] | |||
| exchange = value_stripped_split[-1] | |||
| values.append({ | |||
| 'preference': int(preference), | |||
| 'exchange': self._append_dot(exchange) | |||
| }) | |||
| return { | |||
| 'ttl': self._record_ttl(records[0]), | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_NS(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append(self._append_dot(record['value'])) | |||
| return { | |||
| 'ttl': self._record_ttl(records[0]), | |||
| 'type': _type, | |||
| 'values': values, | |||
| } | |||
| def _data_for_SRV(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| value_stripped = record['value'].strip() | |||
| priority = value_stripped.split(' ')[0] | |||
| weight = value_stripped[len(priority):].strip().split(' ')[0] | |||
| target = value_stripped.split(' ')[-1] | |||
| port = value_stripped[:-len(target)].strip().split(' ')[-1] | |||
| values.append({ | |||
| 'port': int(port), | |||
| 'priority': int(priority), | |||
| 'target': self._append_dot(target), | |||
| 'weight': int(weight) | |||
| }) | |||
| return { | |||
| 'ttl': self._record_ttl(records[0]), | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| _data_for_TXT = _data_for_multiple | |||
| def zone_records(self, zone): | |||
| if zone.name not in self._zone_records: | |||
| try: | |||
| zone_id = self.zone_metadata(zone_name=zone.name)['id'] | |||
| self._zone_records[zone.name] = \ | |||
| self._client.zone_records_get(zone_id) | |||
| except HetznerClientNotFound: | |||
| return [] | |||
| return self._zone_records[zone.name] | |||
| def populate(self, zone, target=False, lenient=False): | |||
| self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, | |||
| target, lenient) | |||
| values = defaultdict(lambda: defaultdict(list)) | |||
| for record in self.zone_records(zone): | |||
| _type = record['type'] | |||
| if _type not in self.SUPPORTS: | |||
| self.log.warning('populate: skipping unsupported %s record', | |||
| _type) | |||
| continue | |||
| values[record['name']][record['type']].append(record) | |||
| before = len(zone.records) | |||
| for name, types in values.items(): | |||
| for _type, records in types.items(): | |||
| data_for = getattr(self, '_data_for_{}'.format(_type)) | |||
| record = Record.new(zone, name, data_for(_type, records), | |||
| source=self, lenient=lenient) | |||
| zone.add_record(record, lenient=lenient) | |||
| exists = zone.name in self._zone_records | |||
| self.log.info('populate: found %s records, exists=%s', | |||
| len(zone.records) - before, exists) | |||
| return exists | |||
| def _params_for_multiple(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'value': value.replace('\\;', ';'), | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_A = _params_for_multiple | |||
| _params_for_AAAA = _params_for_multiple | |||
| def _params_for_CAA(self, record): | |||
| for value in record.values: | |||
| data = '{} {} "{}"'.format(value.flags, value.tag, value.value) | |||
| yield { | |||
| 'value': data, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| def _params_for_single(self, record): | |||
| yield { | |||
| 'value': record.value, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_CNAME = _params_for_single | |||
| def _params_for_MX(self, record): | |||
| for value in record.values: | |||
| data = '{} {}'.format(value.preference, value.exchange) | |||
| yield { | |||
| 'value': data, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_NS = _params_for_multiple | |||
| def _params_for_SRV(self, record): | |||
| for value in record.values: | |||
| data = '{} {} {} {}'.format(value.priority, value.weight, | |||
| value.port, value.target) | |||
| yield { | |||
| 'value': data, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_TXT = _params_for_multiple | |||
| def _apply_Create(self, zone_id, change): | |||
| new = change.new | |||
| params_for = getattr(self, '_params_for_{}'.format(new._type)) | |||
| for params in params_for(new): | |||
| self._client.zone_record_create(zone_id, params['name'], | |||
| params['type'], params['value'], | |||
| params['ttl']) | |||
| def _apply_Update(self, zone_id, change): | |||
| # It's way simpler to delete-then-recreate than to update | |||
| self._apply_Delete(zone_id, change) | |||
| self._apply_Create(zone_id, change) | |||
| def _apply_Delete(self, zone_id, change): | |||
| existing = change.existing | |||
| zone = existing.zone | |||
| for record in self.zone_records(zone): | |||
| if existing.name == record['name'] and \ | |||
| existing._type == record['type']: | |||
| self._client.zone_record_delete(zone_id, record['id']) | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| try: | |||
| zone_id = self.zone_metadata(zone_name=desired.name)['id'] | |||
| except HetznerClientNotFound: | |||
| self.log.debug('_apply: no matching zone, creating domain') | |||
| zone_id = self._client.zone_create(desired.name[:-1])['id'] | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| getattr(self, '_apply_{}'.format(class_name))(zone_id, change) | |||
| # Clear out the cache if any | |||
| self._zone_records.pop(desired.name, None) | |||
| @ -0,0 +1,21 @@ | |||
| manager: | |||
| max_workers: 2 | |||
| providers: | |||
| in: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| zones: | |||
| unit.tests.: | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| alias.tests.: | |||
| alias: unit.tests. | |||
| alias-loop.tests.: | |||
| alias: alias.tests. | |||
| @ -0,0 +1,6 @@ | |||
| manager: | |||
| plan_outputs: | |||
| "doesntexist": | |||
| class: octodns.provider.plan.DoesntExist | |||
| providers: {} | |||
| zones: {} | |||
| @ -0,0 +1,23 @@ | |||
| providers: | |||
| config: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| geo: | |||
| class: helpers.GeoProvider | |||
| nosshfp: | |||
| class: helpers.NoSshFpProvider | |||
| processors: | |||
| no-class: {} | |||
| zones: | |||
| unit.tests.: | |||
| processors: | |||
| - noop | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| @ -0,0 +1,25 @@ | |||
| providers: | |||
| config: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| geo: | |||
| class: helpers.GeoProvider | |||
| nosshfp: | |||
| class: helpers.NoSshFpProvider | |||
| processors: | |||
| # valid class, but it wants a param and we're not passing it | |||
| wants-config: | |||
| class: helpers.WantsConfigProcessor | |||
| zones: | |||
| unit.tests.: | |||
| processors: | |||
| - noop | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| @ -0,0 +1,33 @@ | |||
| providers: | |||
| config: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| geo: | |||
| class: helpers.GeoProvider | |||
| nosshfp: | |||
| class: helpers.NoSshFpProvider | |||
| processors: | |||
| # Just testing config so any processor will do | |||
| noop: | |||
| class: octodns.processor.base.BaseProcessor | |||
| zones: | |||
| unit.tests.: | |||
| processors: | |||
| - noop | |||
| sources: | |||
| - config | |||
| targets: | |||
| - dump | |||
| bad.unit.tests.: | |||
| processors: | |||
| - doesnt-exist | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| @ -0,0 +1,19 @@ | |||
| manager: | |||
| max_workers: 2 | |||
| providers: | |||
| in: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| zones: | |||
| unit.tests.: | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| alias.tests.: | |||
| alias: unit.tests. | |||
| @ -0,0 +1,5 @@ | |||
| --- | |||
| dname: | |||
| ttl: 300 | |||
| type: DNAME | |||
| value: unit.tests. | |||
| @ -0,0 +1,15 @@ | |||
| --- | |||
| urlfwd: | |||
| ttl: 300 | |||
| type: URLFWD | |||
| values: | |||
| - code: 302 | |||
| masking: 2 | |||
| path: '/' | |||
| query: 0 | |||
| target: 'http://www.unit.tests' | |||
| - code: 301 | |||
| masking: 2 | |||
| path: '/target' | |||
| query: 0 | |||
| target: 'http://target.unit.tests' | |||
| @ -0,0 +1,17 @@ | |||
| manager: | |||
| max_workers: 2 | |||
| providers: | |||
| in: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| zones: | |||
| unit.tests.: | |||
| sources: | |||
| - in | |||
| processors: | |||
| - missing | |||
| targets: | |||
| - dump | |||
| @ -0,0 +1,18 @@ | |||
| manager: | |||
| max_workers: 2 | |||
| providers: | |||
| in: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: tests/config | |||
| dump: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: env/YAML_TMP_DIR | |||
| zones: | |||
| unit.tests.: | |||
| sources: | |||
| - in | |||
| targets: | |||
| - dump | |||
| alias.tests.: | |||
| alias: does-not-exists.tests. | |||
| @ -0,0 +1,128 @@ | |||
| { | |||
| "result": [ | |||
| { | |||
| "id": "fc12ab34cd5611334422ab3322997656", | |||
| "type": "SRV", | |||
| "name": "_srv._tcp.unit.tests", | |||
| "data": { | |||
| "service": "_srv", | |||
| "proto": "_tcp", | |||
| "name": "unit.tests", | |||
| "priority": 12, | |||
| "weight": 20, | |||
| "port": 30, | |||
| "target": "foo-2.unit.tests" | |||
| }, | |||
| "proxiable": true, | |||
| "proxied": false, | |||
| "ttl": 600, | |||
| "locked": false, | |||
| "zone_id": "ff12ab34cd5611334422ab3322997650", | |||
| "zone_name": "unit.tests", | |||
| "modified_on": "2017-03-11T18:01:43.940682Z", | |||
| "created_on": "2017-03-11T18:01:43.940682Z", | |||
| "meta": { | |||
| "auto_added": false | |||
| } | |||
| }, | |||
| { | |||
| "id": "fc12ab34cd5611334422ab3322997656", | |||
| "type": "SRV", | |||
| "name": "_srv._tcp.unit.tests", | |||
| "data": { | |||
| "service": "_srv", | |||
| "proto": "_tcp", | |||
| "name": "unit.tests", | |||
| "priority": 10, | |||
| "weight": 20, | |||
| "port": 30, | |||
| "target": "foo-1.unit.tests" | |||
| }, | |||
| "proxiable": true, | |||
| "proxied": false, | |||
| "ttl": 600, | |||
| "locked": false, | |||
| "zone_id": "ff12ab34cd5611334422ab3322997650", | |||
| "zone_name": "unit.tests", | |||
| "modified_on": "2017-03-11T18:01:43.940682Z", | |||
| "created_on": "2017-03-11T18:01:43.940682Z", | |||
| "meta": { | |||
| "auto_added": false | |||
| } | |||
| }, | |||
| { | |||
| "id": "372e67954025e0ba6aaa6d586b9e0b59", | |||
| "type": "LOC", | |||
| "name": "loc.unit.tests", | |||
| "content": "IN LOC 31 58 52.1 S 115 49 11.7 E 20m 10m 10m 2m", | |||
| "proxiable": true, | |||
| "proxied": false, | |||
| "ttl": 300, | |||
| "locked": false, | |||
| "zone_id": "ff12ab34cd5611334422ab3322997650", | |||
| "zone_name": "unit.tests", | |||
| "created_on": "2020-01-28T05:20:00.12345Z", | |||
| "modified_on": "2020-01-28T05:20:00.12345Z", | |||
| "data": { | |||
| "lat_degrees": 31, | |||
| "lat_minutes": 58, | |||
| "lat_seconds": 52.1, | |||
| "lat_direction": "S", | |||
| "long_degrees": 115, | |||
| "long_minutes": 49, | |||
| "long_seconds": 11.7, | |||
| "long_direction": "E", | |||
| "altitude": 20, | |||
| "size": 10, | |||
| "precision_horz": 10, | |||
| "precision_vert": 2 | |||
| }, | |||
| "meta": { | |||
| "auto_added": true, | |||
| "source": "primary" | |||
| } | |||
| }, | |||
| { | |||
| "id": "372e67954025e0ba6aaa6d586b9e0b59", | |||
| "type": "LOC", | |||
| "name": "loc.unit.tests", | |||
| "content": "IN LOC 53 14 10 N 2 18 26 W 20m 10m 1000m 2m", | |||
| "proxiable": true, | |||
| "proxied": false, | |||
| "ttl": 300, | |||
| "locked": false, | |||
| "zone_id": "ff12ab34cd5611334422ab3322997650", | |||
| "zone_name": "unit.tests", | |||
| "created_on": "2020-01-28T05:20:00.12345Z", | |||
| "modified_on": "2020-01-28T05:20:00.12345Z", | |||
| "data": { | |||
| "lat_degrees": 53, | |||
| "lat_minutes": 13, | |||
| "lat_seconds": 10, | |||
| "lat_direction": "N", | |||
| "long_degrees": 2, | |||
| "long_minutes": 18, | |||
| "long_seconds": 26, | |||
| "long_direction": "W", | |||
| "altitude": 20, | |||
| "size": 10, | |||
| "precision_horz": 1000, | |||
| "precision_vert": 2 | |||
| }, | |||
| "meta": { | |||
| "auto_added": true, | |||
| "source": "primary" | |||
| } | |||
| } | |||
| ], | |||
| "result_info": { | |||
| "page": 3, | |||
| "per_page": 10, | |||
| "total_pages": 3, | |||
| "count": 4, | |||
| "total_count": 24 | |||
| }, | |||
| "success": true, | |||
| "errors": [], | |||
| "messages": [] | |||
| } | |||
| @ -0,0 +1,103 @@ | |||
| { | |||
| "result": [ | |||
| { | |||
| "id": "2b1ec1793185213139f22059a165376e", | |||
| "targets": [ | |||
| { | |||
| "target": "url", | |||
| "constraint": { | |||
| "operator": "matches", | |||
| "value": "urlfwd0.unit.tests/" | |||
| } | |||
| } | |||
| ], | |||
| "actions": [ | |||
| { | |||
| "id": "always_use_https" | |||
| } | |||
| ], | |||
| "priority": 4, | |||
| "status": "active", | |||
| "created_on": "2021-06-29T17:14:28.000000Z", | |||
| "modified_on": "2021-06-29T17:15:33.000000Z" | |||
| }, | |||
| { | |||
| "id": "2b1ec1793185213139f22059a165376f", | |||
| "targets": [ | |||
| { | |||
| "target": "url", | |||
| "constraint": { | |||
| "operator": "matches", | |||
| "value": "urlfwd0.unit.tests/*" | |||
| } | |||
| } | |||
| ], | |||
| "actions": [ | |||
| { | |||
| "id": "forwarding_url", | |||
| "value": { | |||
| "url": "https://www.unit.tests/", | |||
| "status_code": 301 | |||
| } | |||
| } | |||
| ], | |||
| "priority": 3, | |||
| "status": "active", | |||
| "created_on": "2021-06-29T17:07:12.000000Z", | |||
| "modified_on": "2021-06-29T17:15:12.000000Z" | |||
| }, | |||
| { | |||
| "id": "2b1ec1793185213139f22059a165377e", | |||
| "targets": [ | |||
| { | |||
| "target": "url", | |||
| "constraint": { | |||
| "operator": "matches", | |||
| "value": "urlfwd1.unit.tests/*" | |||
| } | |||
| } | |||
| ], | |||
| "actions": [ | |||
| { | |||
| "id": "forwarding_url", | |||
| "value": { | |||
| "url": "https://www.unit.tests/", | |||
| "status_code": 302 | |||
| } | |||
| } | |||
| ], | |||
| "priority": 2, | |||
| "status": "active", | |||
| "created_on": "2021-06-28T22:42:27.000000Z", | |||
| "modified_on": "2021-06-28T22:43:13.000000Z" | |||
| }, | |||
| { | |||
| "id": "2a9140b17ffb0e6aed826049eec970b8", | |||
| "targets": [ | |||
| { | |||
| "target": "url", | |||
| "constraint": { | |||
| "operator": "matches", | |||
| "value": "urlfwd2.unit.tests/*" | |||
| } | |||
| } | |||
| ], | |||
| "actions": [ | |||
| { | |||
| "id": "forwarding_url", | |||
| "value": { | |||
| "url": "https://www.unit.tests/", | |||
| "status_code": 301 | |||
| } | |||
| } | |||
| ], | |||
| "priority": 1, | |||
| "status": "active", | |||
| "created_on": "2021-06-25T20:10:50.000000Z", | |||
| "modified_on": "2021-06-28T22:38:10.000000Z" | |||
| } | |||
| ], | |||
| "success": true, | |||
| "errors": [], | |||
| "messages": [] | |||
| } | |||
| @ -0,0 +1,154 @@ | |||
| [ | |||
| { | |||
| "rrset_type": "A", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", | |||
| "rrset_values": [ | |||
| "1.2.3.4", | |||
| "1.2.3.5" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CAA", | |||
| "rrset_ttl": 3600, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", | |||
| "rrset_values": [ | |||
| "0 issue \"ca.unit.tests\"" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SSHFP", | |||
| "rrset_ttl": 3600, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", | |||
| "rrset_values": [ | |||
| "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", | |||
| "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "AAAA", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "aaaa", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA", | |||
| "rrset_values": [ | |||
| "2601:644:500:e210:62f8:1dff:feb8:947a" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CNAME", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "cname", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME", | |||
| "rrset_values": [ | |||
| "unit.tests." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "DNAME", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "dname", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME", | |||
| "rrset_values": [ | |||
| "unit.tests." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CNAME", | |||
| "rrset_ttl": 3600, | |||
| "rrset_name": "excluded", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME", | |||
| "rrset_values": [ | |||
| "unit.tests." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "MX", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "mx", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX", | |||
| "rrset_values": [ | |||
| "10 smtp-4.unit.tests.", | |||
| "20 smtp-2.unit.tests.", | |||
| "30 smtp-3.unit.tests.", | |||
| "40 smtp-1.unit.tests." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "PTR", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "ptr", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR", | |||
| "rrset_values": [ | |||
| "foo.bar.com." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SPF", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "spf", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF", | |||
| "rrset_values": [ | |||
| "\"v=spf1 ip4:192.168.0.1/16-all\"" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "TXT", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "txt", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT", | |||
| "rrset_values": [ | |||
| "\"Bah bah black sheep\"", | |||
| "\"have you any wool.\"", | |||
| "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "A", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "www", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A", | |||
| "rrset_values": [ | |||
| "2.2.3.6" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "A", | |||
| "rrset_ttl": 300, | |||
| "rrset_name": "www.sub", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A", | |||
| "rrset_values": [ | |||
| "2.2.3.6" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "_imap._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 0 0 ." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "_pop3._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 0 0 ." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 600, | |||
| "rrset_name": "_srv._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV", | |||
| "rrset_values": [ | |||
| "10 20 30 foo-1.unit.tests.", | |||
| "12 20 30 foo-2.unit.tests." | |||
| ] | |||
| } | |||
| ] | |||
| @ -0,0 +1,111 @@ | |||
| [ | |||
| { | |||
| "rrset_type": "A", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", | |||
| "rrset_values": [ | |||
| "217.70.184.38" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "MX", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX", | |||
| "rrset_values": [ | |||
| "10 spool.mail.gandi.net.", | |||
| "50 fb.mail.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "TXT", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "@", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT", | |||
| "rrset_values": [ | |||
| "\"v=spf1 include:_mailcust.gandi.net ?all\"" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CNAME", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "webmail", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME", | |||
| "rrset_values": [ | |||
| "webmail.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CNAME", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "www", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME", | |||
| "rrset_values": [ | |||
| "webredir.vip.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "_imap._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 0 0 ." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "_imaps._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 1 993 mail.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "_pop3._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 0 0 ." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "_pop3s._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV", | |||
| "rrset_values": [ | |||
| "10 1 995 mail.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "SRV", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "_submission._tcp", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV", | |||
| "rrset_values": [ | |||
| "0 1 465 mail.gandi.net." | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CDS", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "sub", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS", | |||
| "rrset_values": [ | |||
| "32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0" | |||
| ] | |||
| }, | |||
| { | |||
| "rrset_type": "CNAME", | |||
| "rrset_ttl": 10800, | |||
| "rrset_name": "relative", | |||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME", | |||
| "rrset_values": [ | |||
| "target" | |||
| ] | |||
| } | |||
| ] | |||