diff --git a/octodns/provider/cloudflare.py b/octodns/provider/cloudflare.py index f76e0aa..3d47629 100644 --- a/octodns/provider/cloudflare.py +++ b/octodns/provider/cloudflare.py @@ -14,14 +14,18 @@ from ..record import Record, Update from .base import BaseProvider -class CloudflareAuthenticationError(Exception): - +class CloudflareError(Exception): def __init__(self, data): try: message = data['errors'][0]['message'] except (IndexError, KeyError): - message = 'Authentication error' - super(CloudflareAuthenticationError, self).__init__(message) + message = 'Cloudflare error' + super(CloudflareError, self).__init__(message) + + +class CloudflareAuthenticationError(CloudflareError): + def __init__(self, data): + CloudflareError.__init__(self, data) class CloudflareProvider(BaseProvider): @@ -34,6 +38,12 @@ class CloudflareProvider(BaseProvider): email: dns-manager@example.com # The api key (required) token: foo + # Import CDN enabled records as CNAME to {}.cdn.cloudflare.net. Records + # ending at .cdn.cloudflare.net. will be ignored when this provider is + # not used as the source and the cdn option is enabled. + # + # See: https://support.cloudflare.com/hc/en-us/articles/115000830351 + cdn: false ''' SUPPORTS_GEO = False SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV', @@ -42,16 +52,18 @@ class CloudflareProvider(BaseProvider): MIN_TTL = 120 TIMEOUT = 15 - def __init__(self, id, email, token, *args, **kwargs): + def __init__(self, id, email, token, cdn=False, *args, **kwargs): self.log = getLogger('CloudflareProvider[{}]'.format(id)) - self.log.debug('__init__: id=%s, email=%s, token=***', id, email) - super(CloudflareProvider, self).__init__(id, *args, **kwargs) + self.log.debug('__init__: id=%s, email=%s, token=***, cdn=%s', id, + email, cdn) + super(CloudflareProvider, self).__init__(id, cdn, *args, **kwargs) sess = Session() sess.headers.update({ 'X-Auth-Email': email, 'X-Auth-Key': token, }) + self.cdn = cdn self._sess = sess self._zones = None @@ -64,8 +76,11 @@ class CloudflareProvider(BaseProvider): resp = self._sess.request(method, url, params=params, json=data, timeout=self.TIMEOUT) self.log.debug('_request: status=%d', resp.status_code) + if resp.status_code == 400: + raise CloudflareError(resp.json()) if resp.status_code == 403: raise CloudflareAuthenticationError(resp.json()) + resp.raise_for_status() return resp.json() @@ -87,6 +102,18 @@ class CloudflareProvider(BaseProvider): return self._zones + def _data_for_cdn(self, name, _type, records): + self.log.info('CDN rewrite for %s', records[0]['name']) + _type = "CNAME" + if name == "": + _type = "ALIAS" + + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'value': '{}.cdn.cloudflare.net.'.format(records[0]['name']), + } + def _data_for_multiple(self, _type, records): return { 'ttl': records[0]['ttl'], @@ -200,14 +227,29 @@ class CloudflareProvider(BaseProvider): for name, types in values.items(): for _type, records in types.items(): - # Cloudflare supports ALIAS semantics with root CNAMEs - if _type == 'CNAME' and name == '': - _type = 'ALIAS' + # rewrite Cloudflare proxied records + if self.cdn and records[0]['proxied']: + data = self._data_for_cdn(name, _type, records) + + else: + # Cloudflare supports ALIAS semantics with root CNAMEs + if _type == 'CNAME' and name == '': + _type = 'ALIAS' + + data_for = getattr(self, '_data_for_{}'.format(_type)) + data = data_for(_type, records) - data_for = getattr(self, '_data_for_{}'.format(_type)) - data = data_for(_type, records) record = Record.new(zone, name, data, source=self, lenient=lenient) + + # only one rewrite is needed for names where the proxy is + # enabled at multiple records with a different type but + # the same name + if (self.cdn and records[0]['proxied'] and + record in zone._records[name]): + self.log.info('CDN rewrite %s already in zone', name) + continue + zone.add_record(record) self.log.info('populate: found %s records', @@ -220,6 +262,13 @@ class CloudflareProvider(BaseProvider): new['ttl'] = max(120, new['ttl']) if new == existing: return False + + # If this is a record to enable Cloudflare CDN don't update as + # we don't know the original values. + if (change.record._type in ('ALIAS', 'CNAME') and + change.record.value.endswith('.cdn.cloudflare.net.')): + return False + return True def _contents_for_multiple(self, record): diff --git a/tests/test_octodns_provider_cloudflare.py b/tests/test_octodns_provider_cloudflare.py index 2a71e8f..7bb1b6a 100644 --- a/tests/test_octodns_provider_cloudflare.py +++ b/tests/test_octodns_provider_cloudflare.py @@ -42,6 +42,20 @@ class TestCloudflareProvider(TestCase): def test_populate(self): provider = CloudflareProvider('test', 'email', 'token') + # Bad requests + with requests_mock() as mock: + mock.get(ANY, status_code=400, + text='{"success":false,"errors":[{"code":1101,' + '"message":"request was invalid"}],' + '"messages":[],"result":null}') + + with self.assertRaises(Exception) as ctx: + zone = Zone('unit.tests.', []) + provider.populate(zone) + + self.assertEquals('CloudflareError', type(ctx.exception).__name__) + self.assertEquals('request was invalid', ctx.exception.message) + # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=403, @@ -52,6 +66,8 @@ class TestCloudflareProvider(TestCase): with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) + self.assertEquals('CloudflareAuthenticationError', + type(ctx.exception).__name__) self.assertEquals('Unknown X-Auth-Key or X-Auth-Email', ctx.exception.message) @@ -62,7 +78,9 @@ class TestCloudflareProvider(TestCase): with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals('Authentication error', ctx.exception.message) + self.assertEquals('CloudflareAuthenticationError', + type(ctx.exception).__name__) + self.assertEquals('Cloudflare error', ctx.exception.message) # General error with requests_mock() as mock: @@ -485,3 +503,186 @@ class TestCloudflareProvider(TestCase): 'ttl': 300, 'type': 'CNAME' }, list(contents)[0]) + + def test_cdn(self): + provider = CloudflareProvider('test', 'email', 'token', True) + + # A CNAME for us to transform to ALIAS + provider.zone_records = Mock(return_value=[ + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "CNAME", + "name": "cname.unit.tests", + "content": "www.unit.tests", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "A", + "name": "a.unit.tests", + "content": "1.1.1.1", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "A", + "name": "a.unit.tests", + "content": "1.1.1.2", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "A", + "name": "multi.unit.tests", + "content": "1.1.1.3", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "AAAA", + "name": "multi.unit.tests", + "content": "::1", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + ]) + + zone = Zone('unit.tests.', []) + provider.populate(zone) + + # the two A records get merged into one CNAME record poining to the CDN + self.assertEquals(3, len(zone.records)) + + record = list(zone.records)[0] + self.assertEquals('multi', record.name) + self.assertEquals('multi.unit.tests.', record.fqdn) + self.assertEquals('CNAME', record._type) + self.assertEquals('multi.unit.tests.cdn.cloudflare.net.', record.value) + + record = list(zone.records)[1] + self.assertEquals('cname', record.name) + self.assertEquals('cname.unit.tests.', record.fqdn) + self.assertEquals('CNAME', record._type) + self.assertEquals('cname.unit.tests.cdn.cloudflare.net.', record.value) + + record = list(zone.records)[2] + self.assertEquals('a', record.name) + self.assertEquals('a.unit.tests.', record.fqdn) + self.assertEquals('CNAME', record._type) + self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value) + + # CDN enabled records can't be updated, we don't know the real values + # never point a Cloudflare record to itsself. + wanted = Zone('unit.tests.', []) + wanted.add_record(Record.new(wanted, 'cname', { + 'ttl': 300, + 'type': 'CNAME', + 'value': 'change.unit.tests.cdn.cloudflare.net.' + })) + wanted.add_record(Record.new(wanted, 'new', { + 'ttl': 300, + 'type': 'CNAME', + 'value': 'new.unit.tests.cdn.cloudflare.net.' + })) + wanted.add_record(Record.new(wanted, 'created', { + 'ttl': 300, + 'type': 'CNAME', + 'value': 'www.unit.tests.' + })) + + plan = provider.plan(wanted) + self.assertEquals(1, len(plan.changes)) + + def test_cdn_alias(self): + provider = CloudflareProvider('test', 'email', 'token', True) + + # A CNAME for us to transform to ALIAS + provider.zone_records = Mock(return_value=[ + { + "id": "fc12ab34cd5611334422ab3322997642", + "type": "CNAME", + "name": "unit.tests", + "content": "www.unit.tests", + "proxiable": True, + "proxied": True, + "ttl": 300, + "locked": False, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:43.420689Z", + "created_on": "2017-03-11T18:01:43.420689Z", + "meta": { + "auto_added": False + } + }, + ]) + + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(1, len(zone.records)) + record = list(zone.records)[0] + self.assertEquals('', record.name) + self.assertEquals('unit.tests.', record.fqdn) + self.assertEquals('ALIAS', record._type) + self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value) + + # CDN enabled records can't be updated, we don't know the real values + # never point a Cloudflare record to itsself. + wanted = Zone('unit.tests.', []) + wanted.add_record(Record.new(wanted, '', { + 'ttl': 300, + 'type': 'ALIAS', + 'value': 'change.unit.tests.cdn.cloudflare.net.' + })) + + plan = provider.plan(wanted) + self.assertEquals(False, hasattr(plan, 'changes'))