From 8b63aea89b850cb4e60951136a7edf3a6c475c88 Mon Sep 17 00:00:00 2001 From: Michael Gruener Date: Tue, 1 Mar 2016 22:32:19 +0100 Subject: [PATCH] Module to manage Cloudflare DNS records --- network/cloudflare_dns.py | 536 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 network/cloudflare_dns.py diff --git a/network/cloudflare_dns.py b/network/cloudflare_dns.py new file mode 100644 index 00000000000..7b380450457 --- /dev/null +++ b/network/cloudflare_dns.py @@ -0,0 +1,536 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016 Michael Gruener +# +# 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 . + +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()