Add nsupdate module to manage DNS records on a DNS server
This uses dnspython library
It's greatly inspired by https://github.com/mskarbek/ansible-nsupdate with some rework, better feedbacks and documentation addition
Signed-off-by: nerzhul <loic.blot@unix-experience.fr>
154 | 155 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,357 @@ |
0 |
+#!/usr/bin/python |
|
1 |
+ |
|
2 |
+""" |
|
3 |
+Ansible module to manage DNS records using dnspython |
|
4 |
+(c) 2016, Marcin Skarbek <github@skarbek.name> |
|
5 |
+(c) 2016, Andreas Olsson <andreas@arrakis.se> |
|
6 |
+(c) 2017, Loic Blot <loic.blot@unix-experience.fr> |
|
7 |
+ |
|
8 |
+This module was ported from https://github.com/mskarbek/ansible-nsupdate |
|
9 |
+ |
|
10 |
+This file is part of Ansible |
|
11 |
+ |
|
12 |
+Ansible is free software: you can redistribute it and/or modify |
|
13 |
+it under the terms of the GNU General Public License as published by |
|
14 |
+the Free Software Foundation, either version 3 of the License, or |
|
15 |
+(at your option) any later version. |
|
16 |
+ |
|
17 |
+Ansible is distributed in the hope that it will be useful, |
|
18 |
+but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
19 |
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
20 |
+GNU General Public License for more details. |
|
21 |
+You should have received a copy of the GNU General Public License |
|
22 |
+along with Ansible. If not, see <http://www.gnu.org/licenses/>. |
|
23 |
+""" |
|
24 |
+ |
|
25 |
+ANSIBLE_METADATA = {'status': ['preview'], |
|
26 |
+ 'supported_by': 'community', |
|
27 |
+ 'version': '1.0'} |
|
28 |
+ |
|
29 |
+DOCUMENTATION = ''' |
|
30 |
+--- |
|
31 |
+module: nsupdate |
|
32 |
+ |
|
33 |
+short_description: Manage DNS records. |
|
34 |
+description: |
|
35 |
+ - Create, update and remove DNS records using DDNS updates |
|
36 |
+ - DDNS works well with both bind and Microsoft DNS (see https://technet.microsoft.com/en-us/library/cc961412.aspx) |
|
37 |
+version_added: "2.3" |
|
38 |
+requirements: |
|
39 |
+ - dnspython |
|
40 |
+author: "Loic Blot (@nerzhul)" |
|
41 |
+options: |
|
42 |
+ state: |
|
43 |
+ description: |
|
44 |
+ - Manage DNS record. |
|
45 |
+ choices: ['present', 'absent'] |
|
46 |
+ server: |
|
47 |
+ description: |
|
48 |
+ - Apply DNS modification on this server. |
|
49 |
+ required: true |
|
50 |
+ key_name: |
|
51 |
+ description: |
|
52 |
+ - Use TSIG key name to authenticate against DNS C(server) |
|
53 |
+ key_secret: |
|
54 |
+ description: |
|
55 |
+ - Use TSIG key secret, associated with C(key_name), to authenticate against C(server) |
|
56 |
+ default: 7911 |
|
57 |
+ key_algorithm: |
|
58 |
+ description: |
|
59 |
+ - Specify key algorithm used by C(key_secret). |
|
60 |
+ choices: ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hamc-sha384', |
|
61 |
+ 'hmac-sha512'] |
|
62 |
+ zone: |
|
63 |
+ description: |
|
64 |
+ - DNS record will be modified on this C(zone). |
|
65 |
+ required: true |
|
66 |
+ record: |
|
67 |
+ description: |
|
68 |
+ - Sets the DNS record to modify. |
|
69 |
+ required: true |
|
70 |
+ type: |
|
71 |
+ description: |
|
72 |
+ - Sets the record type. |
|
73 |
+ default: 'A' |
|
74 |
+ ttl: |
|
75 |
+ description: |
|
76 |
+ - Sets the record TTL. |
|
77 |
+ default: 3600 |
|
78 |
+ value: |
|
79 |
+ description: |
|
80 |
+ - Sets the record value. |
|
81 |
+ default: None |
|
82 |
+ |
|
83 |
+''' |
|
84 |
+ |
|
85 |
+EXAMPLES = ''' |
|
86 |
+- name: Add or modify ansible.example.org A to 192.168.1.1" |
|
87 |
+ nsupdate: |
|
88 |
+ key_name: "nsupdate" |
|
89 |
+ key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" |
|
90 |
+ server: "10.1.1.1" |
|
91 |
+ zone: "example.org" |
|
92 |
+ record: "ansible" |
|
93 |
+ value: "192.168.1.1" |
|
94 |
+ |
|
95 |
+- name: Remove puppet.example.org CNAME |
|
96 |
+ nsupdate: |
|
97 |
+ key_name: "nsupdate" |
|
98 |
+ key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" |
|
99 |
+ server: "10.1.1.1" |
|
100 |
+ zone: "example.org" |
|
101 |
+ record: "puppet" |
|
102 |
+ type: "CNAME" |
|
103 |
+''' |
|
104 |
+ |
|
105 |
+RETURN = ''' |
|
106 |
+changed: |
|
107 |
+ description: If module has modified record |
|
108 |
+ returned: success |
|
109 |
+ type: string |
|
110 |
+record: |
|
111 |
+ description: DNS record |
|
112 |
+ returned: success |
|
113 |
+ type: string |
|
114 |
+ sample: 'ansible' |
|
115 |
+ttl: |
|
116 |
+ description: DNS record TTL |
|
117 |
+ returned: success |
|
118 |
+ type: int |
|
119 |
+ sample: 86400 |
|
120 |
+type: |
|
121 |
+ description: DNS record type |
|
122 |
+ returned: success |
|
123 |
+ type: string |
|
124 |
+ sample: 'CNAME' |
|
125 |
+value: |
|
126 |
+ description: DNS record value |
|
127 |
+ returned: success |
|
128 |
+ type: string |
|
129 |
+ sample: '192.168.1.1' |
|
130 |
+zone: |
|
131 |
+ description: DNS record zone |
|
132 |
+ returned: success |
|
133 |
+ type: string |
|
134 |
+ sample: 'example.org.' |
|
135 |
+rc: |
|
136 |
+ description: dnspython return code |
|
137 |
+ returned: always |
|
138 |
+ type: int |
|
139 |
+ sample: 4 |
|
140 |
+rc_str: |
|
141 |
+ description: dnspython return code (string representation) |
|
142 |
+ returned: always |
|
143 |
+ type: string |
|
144 |
+ sample: 'REFUSED' |
|
145 |
+''' |
|
146 |
+ |
|
147 |
+from binascii import Error as binascii_error |
|
148 |
+from socket import error as socket_error |
|
149 |
+ |
|
150 |
+from ansible.module_utils.basic import AnsibleModule |
|
151 |
+from ansible.module_utils.pycompat24 import get_exception |
|
152 |
+ |
|
153 |
+try: |
|
154 |
+ import dns.update |
|
155 |
+ import dns.query |
|
156 |
+ import dns.tsigkeyring |
|
157 |
+ import dns.message |
|
158 |
+ import dns.resolver |
|
159 |
+ |
|
160 |
+ HAVE_DNSPYTHON = True |
|
161 |
+except ImportError: |
|
162 |
+ HAVE_DNSPYTHON = False |
|
163 |
+ |
|
164 |
+ |
|
165 |
+class RecordManager(object): |
|
166 |
+ def __init__(self, module): |
|
167 |
+ self.module = module |
|
168 |
+ |
|
169 |
+ if module.params['zone'][-1] != '.': |
|
170 |
+ self.zone = module.params['zone'] + '.' |
|
171 |
+ else: |
|
172 |
+ self.zone = module.params['zone'] |
|
173 |
+ |
|
174 |
+ if module.params['key_name']: |
|
175 |
+ try: |
|
176 |
+ self.keyring = dns.tsigkeyring.from_text({ |
|
177 |
+ module.params['key_name']: module.params['key_secret'] |
|
178 |
+ }) |
|
179 |
+ except TypeError: |
|
180 |
+ module.fail_json(msg='Missing key_secret') |
|
181 |
+ except binascii_error: |
|
182 |
+ e = get_exception() |
|
183 |
+ module.fail_json(msg='TSIG key error: %s' % str(e)) |
|
184 |
+ else: |
|
185 |
+ self.keyring = None |
|
186 |
+ |
|
187 |
+ if module.params['key_algorithm'] == 'hmac-md5': |
|
188 |
+ self.algorithm = 'HMAC-MD5.SIG-ALG.REG.INT' |
|
189 |
+ else: |
|
190 |
+ self.algorithm = module.params['key_algorithm'] |
|
191 |
+ |
|
192 |
+ self.dns_rc = 0 |
|
193 |
+ |
|
194 |
+ def __do_update(self, update): |
|
195 |
+ response = None |
|
196 |
+ try: |
|
197 |
+ response = dns.query.tcp(update, self.module.params['server'], timeout=10) |
|
198 |
+ except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature): |
|
199 |
+ e = get_exception() |
|
200 |
+ self.module.fail_json(msg='TSIG update error (%s): %s' % (e.__class__.__name__, str(e))) |
|
201 |
+ except (socket_error, dns.exception.Timeout): |
|
202 |
+ e = get_exception() |
|
203 |
+ self.module.fail_json(msg='DNS server error: (%s): %s' % (e.__class__.__name__, str(e))) |
|
204 |
+ return response |
|
205 |
+ |
|
206 |
+ def create_or_update_record(self): |
|
207 |
+ result = {'changed': False, 'failed': False} |
|
208 |
+ |
|
209 |
+ exists = self.record_exists() |
|
210 |
+ if exists in [0, 2]: |
|
211 |
+ if self.module.check_mode: |
|
212 |
+ self.module.exit_json(changed=True) |
|
213 |
+ |
|
214 |
+ if exists == 0: |
|
215 |
+ self.dns_rc = self.create_record() |
|
216 |
+ if self.dns_rc != 0: |
|
217 |
+ result['msg'] = "Failed to create DNS record (rc: %d)" % self.dns_rc |
|
218 |
+ |
|
219 |
+ elif exists == 2: |
|
220 |
+ self.dns_rc = self.modify_record() |
|
221 |
+ if self.dns_rc != 0: |
|
222 |
+ result['msg'] = "Failed to update DNS record (rc: %d)" % self.dns_rc |
|
223 |
+ else: |
|
224 |
+ result['changed'] = False |
|
225 |
+ |
|
226 |
+ if self.dns_rc != 0: |
|
227 |
+ result['failed'] = True |
|
228 |
+ else: |
|
229 |
+ result['changed'] = True |
|
230 |
+ |
|
231 |
+ return result |
|
232 |
+ |
|
233 |
+ def create_record(self): |
|
234 |
+ update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) |
|
235 |
+ try: |
|
236 |
+ update.add(self.module.params['record'], |
|
237 |
+ self.module.params['ttl'], |
|
238 |
+ self.module.params['type'], |
|
239 |
+ self.module.params['value']) |
|
240 |
+ except AttributeError: |
|
241 |
+ self.module.fail_json(msg='value needed when state=present') |
|
242 |
+ except dns.exception.SyntaxError: |
|
243 |
+ self.module.fail_json(msg='Invalid/malformed value') |
|
244 |
+ |
|
245 |
+ response = self.__do_update(update) |
|
246 |
+ return dns.message.Message.rcode(response) |
|
247 |
+ |
|
248 |
+ def modify_record(self): |
|
249 |
+ update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) |
|
250 |
+ update.replace(self.module.params['record'], |
|
251 |
+ self.module.params['ttl'], |
|
252 |
+ self.module.params['type'], |
|
253 |
+ self.module.params['value']) |
|
254 |
+ |
|
255 |
+ response = self.__do_update(update) |
|
256 |
+ return dns.message.Message.rcode(response) |
|
257 |
+ |
|
258 |
+ def remove_record(self): |
|
259 |
+ result = {'changed': False, 'failed': False} |
|
260 |
+ |
|
261 |
+ if self.record_exists() == 0: |
|
262 |
+ return result |
|
263 |
+ |
|
264 |
+ # Check mode and record exists, declared fake change. |
|
265 |
+ if self.module.check_mode: |
|
266 |
+ self.module.exit_json(changed=True) |
|
267 |
+ |
|
268 |
+ update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) |
|
269 |
+ update.delete(self.module.params['record'], self.module.params['type']) |
|
270 |
+ |
|
271 |
+ response = self.__do_update(update) |
|
272 |
+ self.dns_rc = dns.message.Message.rcode(response) |
|
273 |
+ |
|
274 |
+ if self.dns_rc != 0: |
|
275 |
+ result['failed'] = True |
|
276 |
+ result['msg'] = "Failed to delete record (rc: %d)" % self.dns_rc |
|
277 |
+ else: |
|
278 |
+ result['changed'] = True |
|
279 |
+ |
|
280 |
+ return result |
|
281 |
+ |
|
282 |
+ def record_exists(self): |
|
283 |
+ update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) |
|
284 |
+ try: |
|
285 |
+ update.present(self.module.params['record'], self.module.params['type']) |
|
286 |
+ except dns.rdatatype.UnknownRdatatype: |
|
287 |
+ e = get_exception() |
|
288 |
+ self.module.fail_json(msg='Record error: {}'.format(str(e))) |
|
289 |
+ |
|
290 |
+ response = self.__do_update(update) |
|
291 |
+ self.dns_rc = dns.message.Message.rcode(response) |
|
292 |
+ if self.dns_rc == 0: |
|
293 |
+ if self.module.params['state'] == 'absent': |
|
294 |
+ return 1 |
|
295 |
+ try: |
|
296 |
+ update.present(self.module.params['record'], self.module.params['type'], self.module.params['value']) |
|
297 |
+ except AttributeError: |
|
298 |
+ self.module.fail_json(msg='value needed when state=present') |
|
299 |
+ except dns.exception.SyntaxError: |
|
300 |
+ self.module.fail_json(msg='Invalid/malformed value') |
|
301 |
+ response = self.__do_update(update) |
|
302 |
+ self.dns_rc = dns.message.Message.rcode(response) |
|
303 |
+ if self.dns_rc == 0: |
|
304 |
+ return 1 |
|
305 |
+ else: |
|
306 |
+ return 2 |
|
307 |
+ else: |
|
308 |
+ return 0 |
|
309 |
+ |
|
310 |
+ |
|
311 |
+def main(): |
|
312 |
+ tsig_algs = ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', |
|
313 |
+ 'hmac-sha256', 'hamc-sha384', 'hmac-sha512'] |
|
314 |
+ |
|
315 |
+ module = AnsibleModule( |
|
316 |
+ argument_spec=dict( |
|
317 |
+ state=dict(required=False, default='present', choices=['present', 'absent'], type='str'), |
|
318 |
+ server=dict(required=True, type='str'), |
|
319 |
+ key_name=dict(required=False, type='str'), |
|
320 |
+ key_secret=dict(required=False, type='str', no_log=True), |
|
321 |
+ key_algorithm=dict(required=False, default='hmac-md5', choices=tsig_algs, type='str'), |
|
322 |
+ zone=dict(required=True, type='str'), |
|
323 |
+ record=dict(required=True, type='str'), |
|
324 |
+ type=dict(required=False, default='A', type='str'), |
|
325 |
+ ttl=dict(required=False, default=3600, type='int'), |
|
326 |
+ value=dict(required=False, default=None, type='str') |
|
327 |
+ ), |
|
328 |
+ supports_check_mode=True |
|
329 |
+ ) |
|
330 |
+ |
|
331 |
+ if not HAVE_DNSPYTHON: |
|
332 |
+ module.fail_json(msg='python library dnspython required: pip install dnspython') |
|
333 |
+ |
|
334 |
+ if len(module.params["record"]) == 0: |
|
335 |
+ module.fail_json(msg='record cannot be empty.') |
|
336 |
+ |
|
337 |
+ record = RecordManager(module) |
|
338 |
+ result = {} |
|
339 |
+ if module.params["state"] == 'absent': |
|
340 |
+ result = record.remove_record() |
|
341 |
+ elif module.params["state"] == 'present': |
|
342 |
+ result = record.create_or_update_record() |
|
343 |
+ |
|
344 |
+ result['rc'] = record.dns_rc |
|
345 |
+ result['rc_str'] = dns.rcode.to_text(record.dns_rc) |
|
346 |
+ if result['failed']: |
|
347 |
+ module.fail_json(**result) |
|
348 |
+ else: |
|
349 |
+ result['record'] = {'zone': record.zone, 'record': module.params['record'], 'type': module.params['type'], |
|
350 |
+ 'ttl': module.params['ttl'], 'value': module.params['value']} |
|
351 |
+ |
|
352 |
+ module.exit_json(**result) |
|
353 |
+ |
|
354 |
+ |
|
355 |
+if __name__ == '__main__': |
|
356 |
+ main() |