Browse code

New module: nsupdate (#21099)

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>

Loïc Blot authored on 2017/02/13 20:13:23
Showing 2 changed files
... ...
@@ -148,6 +148,7 @@ Ansible Changes By Release
148 148
   * sf_snapshot_schedule_manager
149 149
   * sf_volume_access_group_manager
150 150
 - nginx_status_facts
151
+- nsupdate
151 152
 - omapi_host
152 153
 - openssl:
153 154
   * openssl_privatekey
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()