Merge pull request #1768 from mgruener/cloudflare_dns
Module to manage Cloudflare DNS records
This commit is contained in:
commit
9811210c2c
1 changed files with 536 additions and 0 deletions
536
network/cloudflare_dns.py
Normal file
536
network/cloudflare_dns.py
Normal file
|
@ -0,0 +1,536 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
#
|
||||
# This file is (intends to be) part of Ansible
|
||||
#
|
||||
# Ansible is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Ansible is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import urllib
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: cloudflare_dns
|
||||
author: "Michael Gruener (@mgruener)"
|
||||
version_added: "2.1"
|
||||
short_description: manage Cloudflare DNS records
|
||||
description:
|
||||
- "Manages dns records via the Cloudflare API, see the docs: U(https://api.cloudflare.com/)"
|
||||
options:
|
||||
account_api_token:
|
||||
description:
|
||||
- "Account API token. You can obtain your API key from the bottom of the Cloudflare 'My Account' page, found here: U(https://www.cloudflare.com/a/account)"
|
||||
required: true
|
||||
account_email:
|
||||
description:
|
||||
- "Account email."
|
||||
required: true
|
||||
port:
|
||||
description: Service port. Required for C(type=SRV)
|
||||
required: false
|
||||
default: null
|
||||
priority:
|
||||
description: Record priority. Required for C(type=MX) and C(type=SRV)
|
||||
required: false
|
||||
default: "1"
|
||||
proto:
|
||||
description: Service protocol. Required for C(type=SRV)
|
||||
required: false
|
||||
choices: [ 'tcp', 'udp' ]
|
||||
default: null
|
||||
record:
|
||||
description:
|
||||
- Record to add. Required if C(state=present). Default is C(@) (e.g. the zone name)
|
||||
required: false
|
||||
default: "@"
|
||||
aliases: [ "name" ]
|
||||
service:
|
||||
description: Record service. Required for C(type=SRV)
|
||||
required: false
|
||||
default: null
|
||||
solo:
|
||||
description:
|
||||
- Whether the record should be the only one for that record type and record name. Only use with C(state=present)
|
||||
- This will delete all other records with the same record name and type.
|
||||
required: false
|
||||
default: null
|
||||
state:
|
||||
description:
|
||||
- Whether the record(s) should exist or not
|
||||
required: false
|
||||
choices: [ 'present', 'absent' ]
|
||||
default: present
|
||||
timeout:
|
||||
description:
|
||||
- Timeout for Cloudflare API calls
|
||||
required: false
|
||||
default: 30
|
||||
ttl:
|
||||
description:
|
||||
- The TTL to give the new record. Min 1 (automatic), max 2147483647
|
||||
required: false
|
||||
default: 1 (automatic)
|
||||
type:
|
||||
description:
|
||||
- The type of DNS record to create. Required if C(state=present)
|
||||
required: false
|
||||
choices: [ 'A', 'AAAA', 'CNAME', 'TXT', 'SRV', 'MX', 'NS', 'SPF' ]
|
||||
default: null
|
||||
value:
|
||||
description:
|
||||
- The record value. Required for C(state=present)
|
||||
required: false
|
||||
default: null
|
||||
aliases: [ "content" ]
|
||||
weight:
|
||||
description: Service weight. Required for C(type=SRV)
|
||||
required: false
|
||||
default: "1"
|
||||
zone:
|
||||
description:
|
||||
- The name of the Zone to work with (e.g. "example.com"). The Zone must already exist.
|
||||
required: true
|
||||
aliases: ["domain"]
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
# create a test.my.com A record to point to 127.0.0.01
|
||||
- cloudflare_dns:
|
||||
zone: my.com
|
||||
record: test
|
||||
type: A
|
||||
value: 127.0.0.1
|
||||
account_email: test@example.com
|
||||
account_api_token: dummyapitoken
|
||||
register: record
|
||||
|
||||
# create a my.com CNAME record to example.com
|
||||
- cloudflare_dns:
|
||||
zone: my.com
|
||||
type: CNAME
|
||||
value: example.com
|
||||
state: present
|
||||
account_email: test@example.com
|
||||
account_api_token: dummyapitoken
|
||||
|
||||
# change it's ttl
|
||||
- cloudflare_dns:
|
||||
zone: my.com
|
||||
type: CNAME
|
||||
value: example.com
|
||||
ttl: 600
|
||||
state: present
|
||||
account_email: test@example.com
|
||||
account_api_token: dummyapitoken
|
||||
|
||||
# and delete the record
|
||||
- cloudflare_dns:
|
||||
zone: my.com
|
||||
type: CNAME
|
||||
value: example.com
|
||||
state: absent
|
||||
account_email: test@example.com
|
||||
account_api_token: dummyapitoken
|
||||
|
||||
# create TXT record "test.my.com" with value "unique value"
|
||||
# delete all other TXT records named "test.my.com"
|
||||
- cloudflare_dns:
|
||||
domain: my.com
|
||||
record: test
|
||||
type: TXT
|
||||
value: unique value
|
||||
state: present
|
||||
solo: true
|
||||
account_email: test@example.com
|
||||
account_api_token: dummyapitoken
|
||||
|
||||
# create a SRV record _foo._tcp.my.com
|
||||
- cloudflare_dns:
|
||||
domain: my.com
|
||||
service: foo
|
||||
proto: tcp
|
||||
port: 3500
|
||||
priority: 10
|
||||
weight: 20
|
||||
type: SRV
|
||||
value: fooserver.my.com
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
records:
|
||||
description: >
|
||||
List containing the records for a zone or the data for a newly created record.
|
||||
For details see https://api.cloudflare.com/#dns-records-for-a-zone-properties.
|
||||
returned: success/changed after record creation
|
||||
type: list
|
||||
'''
|
||||
|
||||
class CloudflareAPI(object):
|
||||
|
||||
cf_api_endpoint = 'https://api.cloudflare.com/client/v4'
|
||||
changed = False
|
||||
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
self.account_api_token = module.params['account_api_token']
|
||||
self.account_email = module.params['account_email']
|
||||
self.port = module.params['port']
|
||||
self.priority = module.params['priority']
|
||||
self.proto = module.params['proto']
|
||||
self.record = module.params['record']
|
||||
self.service = module.params['service']
|
||||
self.is_solo = module.params['solo']
|
||||
self.state = module.params['state']
|
||||
self.timeout = module.params['timeout']
|
||||
self.ttl = module.params['ttl']
|
||||
self.type = module.params['type']
|
||||
self.value = module.params['value']
|
||||
self.weight = module.params['weight']
|
||||
self.zone = module.params['zone']
|
||||
|
||||
if self.record == '@':
|
||||
self.record = self.zone
|
||||
|
||||
if (self.type in ['CNAME','NS','MX','SRV']) and (self.value is not None):
|
||||
self.value = self.value.rstrip('.')
|
||||
|
||||
if (self.type == 'SRV'):
|
||||
if (self.proto is not None) and (not self.proto.startswith('_')):
|
||||
self.proto = '_' + self.proto
|
||||
if (self.service is not None) and (not self.service.startswith('_')):
|
||||
self.service = '_' + self.service
|
||||
|
||||
if not self.record.endswith(self.zone):
|
||||
self.record = self.record + '.' + self.zone
|
||||
|
||||
def _cf_simple_api_call(self,api_call,method='GET',payload=None):
|
||||
headers = { 'X-Auth-Email': self.account_email,
|
||||
'X-Auth-Key': self.account_api_token,
|
||||
'Content-Type': 'application/json' }
|
||||
data = None
|
||||
if payload:
|
||||
try:
|
||||
data = json.dumps(payload)
|
||||
except Exception, e:
|
||||
self.module.fail_json(msg="Failed to encode payload as JSON: {0}".format(e))
|
||||
|
||||
resp, info = fetch_url(self.module,
|
||||
self.cf_api_endpoint + api_call,
|
||||
headers=headers,
|
||||
data=data,
|
||||
method=method,
|
||||
timeout=self.timeout)
|
||||
|
||||
if info['status'] not in [200,304,400,401,403,429,405,415]:
|
||||
self.module.fail_json(msg="Failed API call {0}; got unexpected HTTP code {1}".format(api_call,info['status']))
|
||||
|
||||
error_msg = ''
|
||||
if info['status'] == 401:
|
||||
# Unauthorized
|
||||
error_msg = "API user does not have permission; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
elif info['status'] == 403:
|
||||
# Forbidden
|
||||
error_msg = "API request not authenticated; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
elif info['status'] == 429:
|
||||
# Too many requests
|
||||
error_msg = "API client is rate limited; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
elif info['status'] == 405:
|
||||
# Method not allowed
|
||||
error_msg = "API incorrect HTTP method provided; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
elif info['status'] == 415:
|
||||
# Unsupported Media Type
|
||||
error_msg = "API request is not valid JSON; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
elif info ['status'] == 400:
|
||||
# Bad Request
|
||||
error_msg = "API bad request; Status: {0}; Method: {1}: Call: {2}".format(info['status'],method,api_call)
|
||||
|
||||
result = None
|
||||
try:
|
||||
content = resp.read()
|
||||
result = json.loads(content)
|
||||
except AttributeError:
|
||||
error_msg += "; The API response was empty"
|
||||
except JSONDecodeError:
|
||||
error_msg += "; Failed to parse API response: {0}".format(content)
|
||||
|
||||
# received an error status but no data with details on what failed
|
||||
if (info['status'] not in [200,304]) and (result is None):
|
||||
self.module.fail_json(msg=error_msg)
|
||||
|
||||
if not result['success']:
|
||||
error_msg += "; Error details: "
|
||||
for error in result['errors']:
|
||||
error_msg += "code: {0}, error: {1}; ".format(error['code'],error['message'])
|
||||
if 'error_chain' in error:
|
||||
for chain_error in error['error_chain']:
|
||||
error_msg += "code: {0}, error: {1}; ".format(chain_error['code'],chain_error['message'])
|
||||
self.module.fail_json(msg=error_msg)
|
||||
|
||||
return result, info['status']
|
||||
|
||||
def _cf_api_call(self,api_call,method='GET',payload=None):
|
||||
result, status = self._cf_simple_api_call(api_call,method,payload)
|
||||
|
||||
data = result['result']
|
||||
|
||||
if 'result_info' in result:
|
||||
pagination = result['result_info']
|
||||
if pagination['total_pages'] > 1:
|
||||
next_page = int(pagination['page']) + 1
|
||||
parameters = ['page={0}'.format(next_page)]
|
||||
# strip "page" parameter from call parameters (if there are any)
|
||||
if '?' in api_call:
|
||||
raw_api_call,query = api_call.split('?',1)
|
||||
parameters += [param for param in query.split('&') if not param.startswith('page')]
|
||||
else:
|
||||
raw_api_call = api_call
|
||||
while next_page <= pagination['total_pages']:
|
||||
raw_api_call += '?' + '&'.join(parameters)
|
||||
result, status = self._cf_simple_api_call(raw_api_call,method,payload)
|
||||
data += result['result']
|
||||
next_page += 1
|
||||
|
||||
return data, status
|
||||
|
||||
def _get_zone_id(self,zone=None):
|
||||
if not zone:
|
||||
zone = self.zone
|
||||
|
||||
zones = self.get_zones(zone)
|
||||
if len(zones) > 1:
|
||||
self.module.fail_json(msg="More than one zone matches {0}".format(zone))
|
||||
|
||||
if len(zones) < 1:
|
||||
self.module.fail_json(msg="No zone found with name {0}".format(zone))
|
||||
|
||||
return zones[0]['id']
|
||||
|
||||
def get_zones(self,name=None):
|
||||
if not name:
|
||||
name = self.zone
|
||||
param = ''
|
||||
if name:
|
||||
param = '?' + urllib.urlencode({'name' : name})
|
||||
zones,status = self._cf_api_call('/zones' + param)
|
||||
return zones
|
||||
|
||||
def get_dns_records(self,zone_name=None,type=None,record=None,value=''):
|
||||
if not zone_name:
|
||||
zone_name = self.zone
|
||||
if not type:
|
||||
type = self.type
|
||||
if not record:
|
||||
record = self.record
|
||||
# necessary because None as value means to override user
|
||||
# set module value
|
||||
if (not value) and (value is not None):
|
||||
value = self.value
|
||||
|
||||
|
||||
zone_id = self._get_zone_id()
|
||||
api_call = '/zones/{0}/dns_records'.format(zone_id)
|
||||
query = {}
|
||||
if type:
|
||||
query['type'] = type
|
||||
if record:
|
||||
query['name'] = record
|
||||
if value:
|
||||
query['content'] = value
|
||||
if query:
|
||||
api_call += '?' + urllib.urlencode(query)
|
||||
|
||||
records,status = self._cf_api_call(api_call)
|
||||
return records
|
||||
|
||||
def delete_dns_records(self,**kwargs):
|
||||
params = {}
|
||||
for param in ['port','proto','service','solo','type','record','value','weight','zone']:
|
||||
if param in kwargs:
|
||||
params[param] = kwargs[param]
|
||||
else:
|
||||
params[param] = getattr(self,param)
|
||||
|
||||
records = []
|
||||
search_value = params['value']
|
||||
search_record = params['record']
|
||||
if params['type'] == 'SRV':
|
||||
search_value = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value']
|
||||
search_record = params['service'] + '.' + params['proto'] + '.' + params['record']
|
||||
if solo:
|
||||
search_value = None
|
||||
|
||||
records = self.get_dns_records(params['zone'],params['type'],search_record,search_value)
|
||||
|
||||
for rr in records:
|
||||
if solo:
|
||||
if not ((rr['type'] == params['type']) and (rr['name'] == params['record']) and (rr['content'] == params['value'])):
|
||||
self.changed = True
|
||||
if not self.module.check_mode:
|
||||
result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'],rr['id']),'DELETE')
|
||||
else:
|
||||
self.changed = True
|
||||
if not self.module.check_mode:
|
||||
result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'],rr['id']),'DELETE')
|
||||
return self.changed
|
||||
|
||||
def ensure_dns_record(self,**kwargs):
|
||||
params = {}
|
||||
for param in ['port','priority','proto','service','ttl','type','record','value','weight','zone']:
|
||||
if param in kwargs:
|
||||
params[param] = kwargs[param]
|
||||
else:
|
||||
params[param] = getattr(self,param)
|
||||
|
||||
search_value = params['value']
|
||||
search_record = params['record']
|
||||
new_record = None
|
||||
if (params['type'] is None) or (params['record'] is None):
|
||||
self.module.fail_json(msg="You must provide a type and a record to create a new record")
|
||||
|
||||
if (params['type'] in [ 'A','AAAA','CNAME','TXT','MX','NS','SPF']):
|
||||
if not params['value']:
|
||||
self.module.fail_json(msg="You must provide a non-empty value to create this record type")
|
||||
new_record = {
|
||||
"type": params['type'],
|
||||
"name": params['record'],
|
||||
"content": params['value'],
|
||||
"ttl": params['ttl']
|
||||
}
|
||||
|
||||
if params['type'] == 'MX':
|
||||
for attr in [params['priority'],params['value']]:
|
||||
if (attr is None) or (attr == ''):
|
||||
self.module.fail_json(msg="You must provide priority and a value to create this record type")
|
||||
new_record = {
|
||||
"type": params['type'],
|
||||
"name": params['record'],
|
||||
"content": params['value'],
|
||||
"priority": params['priority'],
|
||||
"ttl": params['ttl']
|
||||
}
|
||||
|
||||
if params['type'] == 'SRV':
|
||||
for attr in [params['port'],params['priority'],params['proto'],params['service'],params['weight'],params['value']]:
|
||||
if (attr is None) or (attr == ''):
|
||||
self.module.fail_json(msg="You must provide port, priority, proto, service, weight and a value to create this record type")
|
||||
srv_data = {
|
||||
"target": params['value'],
|
||||
"port": params['port'],
|
||||
"weight": params['weight'],
|
||||
"priority": params['priority'],
|
||||
"name": params['record'],
|
||||
"proto": params['proto'],
|
||||
"service": params['service']
|
||||
}
|
||||
new_record = { "type": params['type'], "ttl": params['ttl'], 'data': srv_data }
|
||||
search_value = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value']
|
||||
search_record = params['service'] + '.' + params['proto'] + '.' + params['record']
|
||||
|
||||
zone_id = self._get_zone_id(params['zone'])
|
||||
records = self.get_dns_records(params['zone'],params['type'],search_record,search_value)
|
||||
# in theory this should be impossible as cloudflare does not allow
|
||||
# the creation of duplicate records but lets cover it anyways
|
||||
if len(records) > 1:
|
||||
return records,self.changed
|
||||
# record already exists, check if ttl must be updated
|
||||
if len(records) == 1:
|
||||
cur_record = records[0]
|
||||
do_update = False
|
||||
if (params['ttl'] is not None) and (cur_record['ttl'] != params['ttl'] ):
|
||||
cur_record['ttl'] = params['ttl']
|
||||
do_update = True
|
||||
if (params['priority'] is not None) and ('priority' in cur_record) and (cur_record['priority'] != params['priority']):
|
||||
cur_record['priority'] = params['priority']
|
||||
do_update = True
|
||||
if ('data' in new_record) and ('data' in cur_record):
|
||||
if (cur_record['data'] > new_record['data']) - (cur_record['data'] < new_record['data']):
|
||||
cur_record['data'] = new_record['data']
|
||||
do_update = True
|
||||
if do_update:
|
||||
if not self.module.check_mode:
|
||||
result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(zone_id,records[0]['id']),'PUT',new_record)
|
||||
self.changed = True
|
||||
return result,self.changed
|
||||
else:
|
||||
return records,self.changed
|
||||
if not self.module.check_mode:
|
||||
result, info = self._cf_api_call('/zones/{0}/dns_records'.format(zone_id),'POST',new_record)
|
||||
self.changed = True
|
||||
return result,self.changed
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec = dict(
|
||||
account_api_token = dict(required=True, no_log=True, type='str'),
|
||||
account_email = dict(required=True, type='str'),
|
||||
port = dict(required=False, default=None, type='int'),
|
||||
priority = dict(required=False, default=1, type='int'),
|
||||
proto = dict(required=False, default=None, choices=[ 'tcp', 'udp' ], type='str'),
|
||||
record = dict(required=False, default='@', aliases=['name'], type='str'),
|
||||
service = dict(required=False, default=None, type='str'),
|
||||
solo = dict(required=False, default=None, type='bool'),
|
||||
state = dict(required=False, default='present', choices=['present', 'absent'], type='str'),
|
||||
timeout = dict(required=False, default=30, type='int'),
|
||||
ttl = dict(required=False, default=1, type='int'),
|
||||
type = dict(required=False, default=None, choices=[ 'A', 'AAAA', 'CNAME', 'TXT', 'SRV', 'MX', 'NS', 'SPF' ], type='str'),
|
||||
value = dict(required=False, default=None, aliases=['content'], type='str'),
|
||||
weight = dict(required=False, default=1, type='int'),
|
||||
zone = dict(required=True, default=None, aliases=['domain'], type='str'),
|
||||
),
|
||||
supports_check_mode = True,
|
||||
required_if = ([
|
||||
('state','present',['record','type']),
|
||||
('type','MX',['priority','value']),
|
||||
('type','SRV',['port','priority','proto','service','value','weight']),
|
||||
('type','A',['value']),
|
||||
('type','AAAA',['value']),
|
||||
('type','CNAME',['value']),
|
||||
('type','TXT',['value']),
|
||||
('type','NS',['value']),
|
||||
('type','SPF',['value'])
|
||||
]
|
||||
),
|
||||
required_one_of = (
|
||||
[['record','value','type']]
|
||||
)
|
||||
)
|
||||
|
||||
changed = False
|
||||
cf_api = CloudflareAPI(module)
|
||||
|
||||
# sanity checks
|
||||
if cf_api.is_solo and cf_api.state == 'absent':
|
||||
module.fail_json(msg="solo=true can only be used with state=present")
|
||||
|
||||
# perform add, delete or update (only the TTL can be updated) of one or
|
||||
# more records
|
||||
if cf_api.state == 'present':
|
||||
# delete all records matching record name + type
|
||||
if cf_api.is_solo:
|
||||
changed = cf_api.delete_dns_records(solo=cf_api.is_solo)
|
||||
result,changed = cf_api.ensure_dns_record()
|
||||
module.exit_json(changed=changed,result={'records': result})
|
||||
else:
|
||||
# force solo to False, just to be sure
|
||||
changed = cf_api.delete_dns_records(solo=False)
|
||||
module.exit_json(changed=changed)
|
||||
|
||||
# import module snippets
|
||||
from ansible.module_utils.basic import *
|
||||
from ansible.module_utils.urls import *
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Loading…
Reference in a new issue