|
|
|
@ -10,6 +10,7 @@ from copy import deepcopy |
|
|
|
from logging import getLogger |
|
|
|
from requests import Session |
|
|
|
from time import sleep |
|
|
|
from urllib.parse import urlsplit |
|
|
|
|
|
|
|
from ..record import Record, Update |
|
|
|
from .base import BaseProvider |
|
|
|
@ -76,7 +77,7 @@ class CloudflareProvider(BaseProvider): |
|
|
|
SUPPORTS_GEO = False |
|
|
|
SUPPORTS_DYNAMIC = False |
|
|
|
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'LOC', 'MX', 'NS', |
|
|
|
'PTR', 'SRV', 'SPF', 'TXT')) |
|
|
|
'PTR', 'SRV', 'SPF', 'TXT', 'URLFWD')) |
|
|
|
|
|
|
|
MIN_TTL = 120 |
|
|
|
TIMEOUT = 15 |
|
|
|
@ -286,6 +287,22 @@ class CloudflareProvider(BaseProvider): |
|
|
|
'values': values |
|
|
|
} |
|
|
|
|
|
|
|
def _data_for_URLFWD(self, _type, records): |
|
|
|
values = [] |
|
|
|
for r in records: |
|
|
|
values.append({ |
|
|
|
'path': r['path'], |
|
|
|
'target': r['url'], |
|
|
|
'code': r['status_code'], |
|
|
|
'masking': 2, |
|
|
|
'query': 0, |
|
|
|
}) |
|
|
|
return { |
|
|
|
'type': _type, |
|
|
|
'ttl': 3600, # ttl does not exist for this type, forcing a setting |
|
|
|
'values': values |
|
|
|
} |
|
|
|
|
|
|
|
def zone_records(self, zone): |
|
|
|
if zone.name not in self._zone_records: |
|
|
|
zone_id = self.zones.get(zone.name, False) |
|
|
|
@ -305,6 +322,10 @@ class CloudflareProvider(BaseProvider): |
|
|
|
else: |
|
|
|
page = None |
|
|
|
|
|
|
|
path = '/zones/{}/pagerules'.format(zone_id) |
|
|
|
resp = self._try_request('GET', path, params={'status': 'active'}) |
|
|
|
records += resp['result'] |
|
|
|
|
|
|
|
self._zone_records[zone.name] = records |
|
|
|
|
|
|
|
return self._zone_records[zone.name] |
|
|
|
@ -341,10 +362,30 @@ class CloudflareProvider(BaseProvider): |
|
|
|
exists = True |
|
|
|
values = defaultdict(lambda: defaultdict(list)) |
|
|
|
for record in records: |
|
|
|
name = zone.hostname_from_fqdn(record['name']) |
|
|
|
_type = record['type'] |
|
|
|
if _type in self.SUPPORTS: |
|
|
|
values[name][record['type']].append(record) |
|
|
|
if 'targets' in record: |
|
|
|
# assumption, targets will always contain 1 target |
|
|
|
# API documentation only indicates 'url' as the only target |
|
|
|
# if record['targets'][0]['target'] == 'url': |
|
|
|
uri = record['targets'][0]['constraint']['value'] |
|
|
|
uri = '//' + uri if not uri.startswith('http') else uri |
|
|
|
parsed_uri = urlsplit(uri) |
|
|
|
name = zone.hostname_from_fqdn(parsed_uri.netloc) |
|
|
|
_path = parsed_uri.path |
|
|
|
_type = 'URLFWD' |
|
|
|
# assumption, actions will always contain 1 action |
|
|
|
if record['actions'][0]['id'] == 'forwarding_url': |
|
|
|
_values = record['actions'][0]['value'] |
|
|
|
_values['path'] = _path |
|
|
|
# no ttl set by pagerule, creating one |
|
|
|
_values['ttl'] = 3600 |
|
|
|
values[name][_type].append(_values) |
|
|
|
# the dns_records branch |
|
|
|
# elif 'name' in record: |
|
|
|
else: |
|
|
|
name = zone.hostname_from_fqdn(record['name']) |
|
|
|
_type = record['type'] |
|
|
|
if _type in self.SUPPORTS: |
|
|
|
values[name][record['type']].append(record) |
|
|
|
|
|
|
|
for name, types in values.items(): |
|
|
|
for _type, records in types.items(): |
|
|
|
@ -473,6 +514,31 @@ class CloudflareProvider(BaseProvider): |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
def _contents_for_URLFWD(self, record): |
|
|
|
name = record.fqdn[:-1] |
|
|
|
for value in record.values: |
|
|
|
yield { |
|
|
|
'targets': [ |
|
|
|
{ |
|
|
|
'target': 'url', |
|
|
|
'constraint': { |
|
|
|
'operator': 'matches', |
|
|
|
'value': name + value.path |
|
|
|
} |
|
|
|
} |
|
|
|
], |
|
|
|
'actions': [ |
|
|
|
{ |
|
|
|
'id': 'forwarding_url', |
|
|
|
'value': { |
|
|
|
'url': value.target, |
|
|
|
'status_code': value.code, |
|
|
|
} |
|
|
|
} |
|
|
|
], |
|
|
|
'status': 'active', |
|
|
|
} |
|
|
|
|
|
|
|
def _record_is_proxied(self, record): |
|
|
|
return ( |
|
|
|
not self.cdn and |
|
|
|
@ -488,20 +554,25 @@ class CloudflareProvider(BaseProvider): |
|
|
|
if _type == 'ALIAS': |
|
|
|
_type = 'CNAME' |
|
|
|
|
|
|
|
contents_for = getattr(self, '_contents_for_{}'.format(_type)) |
|
|
|
for content in contents_for(record): |
|
|
|
content.update({ |
|
|
|
'name': name, |
|
|
|
'type': _type, |
|
|
|
'ttl': ttl, |
|
|
|
}) |
|
|
|
|
|
|
|
if _type in _PROXIABLE_RECORD_TYPES: |
|
|
|
if _type == 'URLFWD': |
|
|
|
contents_for = getattr(self, '_contents_for_{}'.format(_type)) |
|
|
|
for content in contents_for(record): |
|
|
|
yield content |
|
|
|
else: |
|
|
|
contents_for = getattr(self, '_contents_for_{}'.format(_type)) |
|
|
|
for content in contents_for(record): |
|
|
|
content.update({ |
|
|
|
'proxied': self._record_is_proxied(record) |
|
|
|
'name': name, |
|
|
|
'type': _type, |
|
|
|
'ttl': ttl, |
|
|
|
}) |
|
|
|
|
|
|
|
yield content |
|
|
|
if _type in _PROXIABLE_RECORD_TYPES: |
|
|
|
content.update({ |
|
|
|
'proxied': self._record_is_proxied(record) |
|
|
|
}) |
|
|
|
|
|
|
|
yield content |
|
|
|
|
|
|
|
def _gen_key(self, data): |
|
|
|
# Note that most CF record data has a `content` field the value of |
|
|
|
@ -515,7 +586,11 @@ class CloudflareProvider(BaseProvider): |
|
|
|
# BUT... there are exceptions. MX, CAA, LOC and SRV don't have a simple |
|
|
|
# content as things are currently implemented so we need to handle |
|
|
|
# those explicitly and create unique/hashable strings for them. |
|
|
|
_type = data['type'] |
|
|
|
# AND... for URLFWD/Redirects additional adventures are created. |
|
|
|
if 'targets' in data: |
|
|
|
_type = 'URLFWD' |
|
|
|
else: |
|
|
|
_type = data['type'] |
|
|
|
if _type == 'MX': |
|
|
|
return '{priority} {content}'.format(**data) |
|
|
|
elif _type == 'CAA': |
|
|
|
@ -540,12 +615,28 @@ class CloudflareProvider(BaseProvider): |
|
|
|
'{precision_horz}', |
|
|
|
'{precision_vert}') |
|
|
|
return ' '.join(loc).format(**data) |
|
|
|
elif _type == 'URLFWD': |
|
|
|
uri = data['targets'][0]['constraint']['value'] |
|
|
|
uri = '//' + uri if not uri.startswith('http') else uri |
|
|
|
parsed_uri = urlsplit(uri) |
|
|
|
ret = {} |
|
|
|
ret.update(data['actions'][0]['value']) |
|
|
|
ret.update({'name': parsed_uri.netloc, 'path': parsed_uri.path}) |
|
|
|
urlfwd = ( |
|
|
|
'{name}', |
|
|
|
'{path}', |
|
|
|
'{url}', |
|
|
|
'{status_code}') |
|
|
|
return ' '.join(urlfwd).format(**ret) |
|
|
|
return data['content'] |
|
|
|
|
|
|
|
def _apply_Create(self, change): |
|
|
|
new = change.new |
|
|
|
zone_id = self.zones[new.zone.name] |
|
|
|
path = '/zones/{}/dns_records'.format(zone_id) |
|
|
|
if new._type == 'URLFWD': |
|
|
|
path = '/zones/{}/pagerules'.format(zone_id) |
|
|
|
else: |
|
|
|
path = '/zones/{}/dns_records'.format(zone_id) |
|
|
|
for content in self._gen_data(new): |
|
|
|
self._try_request('POST', path, data=content) |
|
|
|
|
|
|
|
@ -558,14 +649,28 @@ class CloudflareProvider(BaseProvider): |
|
|
|
existing = {} |
|
|
|
# Find all of the existing CF records for this name & type |
|
|
|
for record in self.zone_records(zone): |
|
|
|
name = zone.hostname_from_fqdn(record['name']) |
|
|
|
if 'targets' in record: |
|
|
|
uri = record['targets'][0]['constraint']['value'] |
|
|
|
uri = '//' + uri if not uri.startswith('http') else uri |
|
|
|
parsed_uri = urlsplit(uri) |
|
|
|
name = zone.hostname_from_fqdn(parsed_uri.netloc) |
|
|
|
_path = parsed_uri.path |
|
|
|
# assumption, populate will catch this contition |
|
|
|
# if record['actions'][0]['id'] == 'forwarding_url': |
|
|
|
_values = record['actions'][0]['value'] |
|
|
|
_values['path'] = _path |
|
|
|
_values['ttl'] = 3600 |
|
|
|
_values['type'] = 'URLFWD' |
|
|
|
record.update(_values) |
|
|
|
else: |
|
|
|
name = zone.hostname_from_fqdn(record['name']) |
|
|
|
# Use the _record_for so that we include all of standard |
|
|
|
# conversion logic |
|
|
|
r = self._record_for(zone, name, record['type'], [record], True) |
|
|
|
if hostname == r.name and _type == r._type: |
|
|
|
# Round trip the single value through a record to contents flow |
|
|
|
# to get a consistent _gen_data result that matches what |
|
|
|
# went in to new_contents |
|
|
|
# Round trip the single value through a record to contents |
|
|
|
# flow to get a consistent _gen_data result that matches |
|
|
|
# what went in to new_contents |
|
|
|
data = next(self._gen_data(r)) |
|
|
|
|
|
|
|
# Record the record_id and data for this existing record |
|
|
|
@ -633,7 +738,10 @@ class CloudflareProvider(BaseProvider): |
|
|
|
# otherwise required, just makes things deterministic |
|
|
|
|
|
|
|
# Creates |
|
|
|
path = '/zones/{}/dns_records'.format(zone_id) |
|
|
|
if _type == 'URLFWD': |
|
|
|
path = '/zones/{}/pagerules'.format(zone_id) |
|
|
|
else: |
|
|
|
path = '/zones/{}/dns_records'.format(zone_id) |
|
|
|
for _, data in sorted(creates.items()): |
|
|
|
self.log.debug('_apply_Update: creating %s', data) |
|
|
|
self._try_request('POST', path, data=data) |
|
|
|
@ -643,7 +751,10 @@ class CloudflareProvider(BaseProvider): |
|
|
|
record_id = info['record_id'] |
|
|
|
data = info['data'] |
|
|
|
old_data = info['old_data'] |
|
|
|
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) |
|
|
|
if _type == 'URLFWD': |
|
|
|
path = '/zones/{}/pagerules/{}'.format(zone_id, record_id) |
|
|
|
else: |
|
|
|
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) |
|
|
|
self.log.debug('_apply_Update: updating %s, %s -> %s', |
|
|
|
record_id, data, old_data) |
|
|
|
self._try_request('PUT', path, data=data) |
|
|
|
@ -652,7 +763,10 @@ class CloudflareProvider(BaseProvider): |
|
|
|
for _, info in sorted(deletes.items()): |
|
|
|
record_id = info['record_id'] |
|
|
|
old_data = info['data'] |
|
|
|
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) |
|
|
|
if _type == 'URLFWD': |
|
|
|
path = '/zones/{}/pagerules/{}'.format(zone_id, record_id) |
|
|
|
else: |
|
|
|
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) |
|
|
|
self.log.debug('_apply_Update: removing %s, %s', record_id, |
|
|
|
old_data) |
|
|
|
self._try_request('DELETE', path) |
|
|
|
@ -664,11 +778,24 @@ class CloudflareProvider(BaseProvider): |
|
|
|
existing_type = 'CNAME' if existing._type == 'ALIAS' \ |
|
|
|
else existing._type |
|
|
|
for record in self.zone_records(existing.zone): |
|
|
|
if existing_name == record['name'] and \ |
|
|
|
existing_type == record['type']: |
|
|
|
path = '/zones/{}/dns_records/{}'.format(record['zone_id'], |
|
|
|
record['id']) |
|
|
|
self._try_request('DELETE', path) |
|
|
|
if 'targets' in record: |
|
|
|
uri = record['targets'][0]['constraint']['value'] |
|
|
|
uri = '//' + uri if not uri.startswith('http') else uri |
|
|
|
parsed_uri = urlsplit(uri) |
|
|
|
record_name = parsed_uri.netloc |
|
|
|
record_type = 'URLFWD' |
|
|
|
zone_id = self.zones.get(existing.zone.name, False) |
|
|
|
if existing_name == record_name and \ |
|
|
|
existing_type == record_type: |
|
|
|
path = '/zones/{}/pagerules/{}' \ |
|
|
|
.format(zone_id, record['id']) |
|
|
|
self._try_request('DELETE', path) |
|
|
|
else: |
|
|
|
if existing_name == record['name'] and \ |
|
|
|
existing_type == record['type']: |
|
|
|
path = '/zones/{}/dns_records/{}' \ |
|
|
|
.format(record['zone_id'], record['id']) |
|
|
|
self._try_request('DELETE', path) |
|
|
|
|
|
|
|
def _apply(self, plan): |
|
|
|
desired = plan.desired |
|
|
|
|