#!/usr/bin/python
# -*- coding: utf-8 -*-
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
#
# This file is 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/>.
try :
import json
except ImportError :
try :
import simplejson as json
except ImportError :
# Let snippet from module_utils/basic.py return a proper error in this case
pass
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 . Must be between 120 and 2 , 147 , 483 , 647 seconds , or 1 for automatic .
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.1
- 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 = '''
record :
description : dictionary containing the record data
returned : success , except on record deletion
type : dictionary
contains :
content :
description : the record content ( details depend on record type )
returned : success
type : string
sample : 192.168 .100 .20
created_on :
description : the record creation date
returned : success
type : string
sample : 2016 - 03 - 25 T19 : 09 : 42.516553 Z
data :
description : additional record data
returned : success , if type is SRV
type : dictionary
sample : {
name : " jabber " ,
port : 8080 ,
priority : 10 ,
proto : " _tcp " ,
service : " _xmpp " ,
target : " jabberhost.sample.com " ,
weight : 5 ,
}
id :
description : the record id
returned : success
type : string
sample : f9efb0549e96abcb750de63b38c9576e
locked :
description : No documentation available
returned : success
type : boolean
sample : False
meta :
description : No documentation available
returned : success
type : dictionary
sample : { auto_added : false }
modified_on :
description : record modification date
returned : success
type : string
sample : 2016 - 03 - 25 T19 : 09 : 42.516553 Z
name :
description : the record name as FQDN ( including _service and _proto for SRV )
returned : success
type : string
sample : www . sample . com
priority :
description : priority of the MX record
returned : success , if type is MX
type : int
sample : 10
proxiable :
description : whether this record can be proxied through cloudflare
returned : success
type : boolean
sample : False
proxied :
description : whether the record is proxied through cloudflare
returned : success
type : boolean
sample : False
ttl :
description : the time - to - live for the record
returned : success
type : int
sample : 300
type :
description : the record type
returned : success
type : string
sample : A
zone_id :
description : the id of the zone containing the record
returned : success
type : string
sample : abcede0bf9f0066f94029d2e6b73856a
zone_name :
description : the name of the zone containing the record
returned : success
type : string
sample : sample . com
'''
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 ( )
except AttributeError :
if info [ ' body ' ] :
content = info [ ' body ' ]
else :
error_msg + = " ; The API response was empty "
if content :
try :
result = json . loads ( content )
except json . 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 = [ ]
content = params [ ' value ' ]
search_record = params [ ' record ' ]
if params [ ' type ' ] == ' SRV ' :
content = str ( params [ ' weight ' ] ) + ' \t ' + str ( params [ ' port ' ] ) + ' \t ' + params [ ' value ' ]
search_record = params [ ' service ' ] + ' . ' + params [ ' proto ' ] + ' . ' + params [ ' record ' ]
if params [ ' solo ' ] :
search_value = None
else :
search_value = content
records = self . get_dns_records ( params [ ' zone ' ] , params [ ' type ' ] , search_record , search_value )
for rr in records :
if params [ ' solo ' ] :
if not ( ( rr [ ' type ' ] == params [ ' type ' ] ) and ( rr [ ' name ' ] == search_record ) and ( rr [ ' content ' ] == content ) ) :
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 " )
# there can only be one CNAME per record
# ignoring the value when searching for existing
# CNAME records allows us to update the value if it
# changes
if params [ ' type ' ] == ' CNAME ' :
search_value = None
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 ' ] [ : - len ( ' . ' + params [ ' zone ' ] ) ] ,
" 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 :
self . module . fail_json ( msg = " More than one record already exists for the given attributes. That should be impossible, please open an issue! " )
# record already exists, check if it 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 ' ] ) :
do_update = True
if ( params [ ' priority ' ] is not None ) and ( ' priority ' in cur_record ) and ( 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 ' ] ) :
do_update = True
if ( type == ' CNAME ' ) and ( cur_record [ ' content ' ] != new_record [ ' content ' ] ) :
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 ( )
if isinstance ( result , list ) :
module . exit_json ( changed = changed , result = { ' record ' : result [ 0 ] } )
else :
module . exit_json ( changed = changed , result = { ' record ' : 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 ( )