diff --git a/octodns/processor/acme.py b/octodns/processor/acme.py new file mode 100644 index 0000000..685130d --- /dev/null +++ b/octodns/processor/acme.py @@ -0,0 +1,64 @@ +# +# +# + +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, zone, *args, **kwargs): + ret = self._clone_zone(zone) + for record in zone.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) + ret.add_record(record) + return ret + + def process_target_zone(self, zone, *args, **kwargs): + ret = self._clone_zone(zone) + for record in zone.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) + continue + ret.add_record(record) + + return ret diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index 3bd0b54..f87538d 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -627,11 +627,14 @@ class Ns1Provider(BaseProvider): for c in countries: geos.add('{}-{}'.format(continent, c)) - # States are easy too, just assume NA-US (CA providences aren't - # supported by octoDNS currently) + # States and provinces are easy too, + # just assume NA-US or NA-CA for state in meta.get('us_state', []): geos.add('NA-US-{}'.format(state)) + for province in meta.get('ca_province', []): + geos.add('NA-CA-{}'.format(province)) + if geos: # There are geos, combine them with any existing geos for this # pool and recorded the sorted unique set of them @@ -1150,12 +1153,15 @@ class Ns1Provider(BaseProvider): country = set() georegion = set() us_state = set() + ca_province = set() for geo in rule.data.get('geos', []): n = len(geo) if n == 8: # US state, e.g. NA-US-KY - us_state.add(geo[-2:]) + # CA province, e.g. NA-CA-NL + us_state.add(geo[-2:]) if "NA-US" in geo \ + else ca_province.add(geo[-2:]) # For filtering. State filtering is done by the country # filter has_country = True @@ -1188,7 +1194,7 @@ class Ns1Provider(BaseProvider): 'meta': georegion_meta, } - if country or us_state: + if country or us_state or ca_province: # If there's country and/or states its a country pool, # countries and states can coexist as they're handled by the # same step in the filterchain (countries and georegions @@ -1199,11 +1205,12 @@ class Ns1Provider(BaseProvider): country_state_meta['country'] = sorted(country) if us_state: country_state_meta['us_state'] = sorted(us_state) + if ca_province: + country_state_meta['ca_province'] = sorted(ca_province) regions['{}__country'.format(pool_name)] = { 'meta': country_state_meta, } - - if not georegion and not country and not us_state: + elif not georegion: # If there's no targeting it's a catchall regions['{}__catchall'.format(pool_name)] = { 'meta': meta, diff --git a/tests/test_octodns_processor_acme.py b/tests/test_octodns_processor_acme.py new file mode 100644 index 0000000..c927608 --- /dev/null +++ b/tests/test_octodns_processor_acme.py @@ -0,0 +1,103 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +from octodns.processor.acme import AcmeMangingProcessor +from octodns.record import Record +from octodns.zone import Zone + +zone = Zone('unit.tests.', []) +records = { + 'root-unowned': Record.new(zone, '_acme-challenge', { + 'ttl': 30, + 'type': 'TXT', + 'value': 'magic bit', + }), + 'sub-unowned': Record.new(zone, '_acme-challenge.sub-unowned', { + 'ttl': 30, + 'type': 'TXT', + 'value': 'magic bit', + }), + 'not-txt': Record.new(zone, '_acme-challenge.not-txt', { + 'ttl': 30, + 'type': 'AAAA', + 'value': '::1', + }), + 'not-acme': Record.new(zone, 'not-acme', { + 'ttl': 30, + 'type': 'TXT', + 'value': 'Hello World!', + }), + 'managed': Record.new(zone, '_acme-challenge.managed', { + 'ttl': 30, + 'type': 'TXT', + 'value': 'magic bit', + }), + 'owned': Record.new(zone, '_acme-challenge.owned', { + 'ttl': 30, + 'type': 'TXT', + 'values': ['*octoDNS*', 'magic bit'], + }), + 'going-away': Record.new(zone, '_acme-challenge.going-away', { + 'ttl': 30, + 'type': 'TXT', + 'values': ['*octoDNS*', 'magic bit'], + }), +} + + +class TestAcmeMangingProcessor(TestCase): + + def test_process_zones(self): + acme = AcmeMangingProcessor('acme') + + source = Zone(zone.name, []) + # Unrelated stuff that should be untouched + source.add_record(records['not-txt']) + source.add_record(records['not-acme']) + # A managed acme that will have ownership value added + source.add_record(records['managed']) + + got = acme.process_source_zone(source) + self.assertEquals([ + '_acme-challenge.managed', + '_acme-challenge.not-txt', + 'not-acme', + ], sorted([r.name for r in got.records])) + managed = None + for record in got.records: + print(record.name) + if record.name.endswith('managed'): + managed = record + break + self.assertTrue(managed) + # Ownership was marked with an extra value + self.assertEquals(['*octoDNS*', 'magic bit'], record.values) + + existing = Zone(zone.name, []) + # Unrelated stuff that should be untouched + existing.add_record(records['not-txt']) + existing.add_record(records['not-acme']) + # Stuff that will be ignored + existing.add_record(records['root-unowned']) + existing.add_record(records['sub-unowned']) + # A managed acme that needs ownership value added + existing.add_record(records['managed']) + # A managed acme that has ownershp managed + existing.add_record(records['owned']) + # A managed acme that needs to go away + existing.add_record(records['going-away']) + + got = acme.process_target_zone(existing) + self.assertEquals([ + '_acme-challenge.going-away', + '_acme-challenge.managed', + '_acme-challenge.not-txt', + '_acme-challenge.owned', + 'not-acme' + ], sorted([r.name for r in got.records])) diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py index 3e239b3..6243348 100644 --- a/tests/test_octodns_provider_ns1.py +++ b/tests/test_octodns_provider_ns1.py @@ -1260,7 +1260,7 @@ class TestNs1ProviderDynamic(TestCase): rule0 = record.data['dynamic']['rules'][0] rule1 = record.data['dynamic']['rules'][1] rule0['geos'] = ['AF', 'EU'] - rule1['geos'] = ['NA-US-CA'] + rule1['geos'] = ['NA-US-CA', 'NA-CA-NL'] ret, _ = provider._params_for_A(record) self.assertEquals(10, len(ret['answers'])) exp = Ns1Provider._FILTER_CHAIN_WITH_REGION_AND_COUNTRY(provider, @@ -1275,7 +1275,8 @@ class TestNs1ProviderDynamic(TestCase): 'iad__country': { 'meta': { 'note': 'rule-order:1', - 'us_state': ['CA'] + 'us_state': ['CA'], + 'ca_province': ['NL'] } }, 'lhr__georegion': { @@ -1637,8 +1638,9 @@ class TestNs1ProviderDynamic(TestCase): 'lhr__country': { 'meta': { 'note': 'rule-order:1 fallback:iad', - 'country': ['CA'], + 'country': ['MX'], 'us_state': ['OR'], + 'ca_province': ['NL'] }, }, # iad will use the old style "plain" region naming. We won't @@ -1682,8 +1684,9 @@ class TestNs1ProviderDynamic(TestCase): '_order': '1', 'geos': [ 'AF', - 'NA-CA', - 'NA-US-OR', + 'NA-CA-NL', + 'NA-MX', + 'NA-US-OR' ], 'pool': 'lhr', }, {