#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2016, Marcin Skarbek # Copyright (c) 2016, Andreas Olsson # Copyright (c) 2017, Loic Blot # # This module was ported from https://github.com/mskarbek/ansible-nsupdate # # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- module: nsupdate short_description: Manage DNS records description: - Create, update and remove DNS records using DDNS updates requirements: - dnspython author: "Loic Blot (@nerzhul)" extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: state: description: - Manage DNS record. choices: ['present', 'absent'] default: 'present' type: str server: description: - Apply DNS modification on this server, specified by IPv4 or IPv6 address. required: true type: str port: description: - Use this TCP port when connecting to O(server). default: 53 type: int key_name: description: - Use TSIG key name to authenticate against DNS O(server) type: str key_secret: description: - Use TSIG key secret, associated with O(key_name), to authenticate against O(server) type: str key_algorithm: description: - Specify key algorithm used by O(key_secret). choices: ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'] default: 'hmac-md5' type: str zone: description: - DNS record will be modified on this O(zone). - When omitted DNS will be queried to attempt finding the correct zone. type: str record: description: - Sets the DNS record to modify. When zone is omitted this has to be absolute (ending with a dot). required: true type: str type: description: - Sets the record type. default: 'A' type: str ttl: description: - Sets the record TTL. default: 3600 type: int value: description: - Sets the record value. type: list elements: str protocol: description: - Sets the transport protocol (TCP or UDP). TCP is the recommended and a more robust option. default: 'tcp' choices: ['tcp', 'udp'] type: str ''' EXAMPLES = ''' - name: Add or modify ansible.example.org A to 192.168.1.1" community.general.nsupdate: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" server: "10.1.1.1" zone: "example.org" record: "ansible" value: "192.168.1.1" - name: Add or modify ansible.example.org A to 192.168.1.1, 192.168.1.2 and 192.168.1.3" community.general.nsupdate: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" server: "10.1.1.1" zone: "example.org" record: "ansible" value: ["192.168.1.1", "192.168.1.2", "192.168.1.3"] - name: Remove puppet.example.org CNAME community.general.nsupdate: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" server: "10.1.1.1" zone: "example.org" record: "puppet" type: "CNAME" state: absent - name: Add 1.1.168.192.in-addr.arpa. PTR for ansible.example.org community.general.nsupdate: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" server: "10.1.1.1" record: "1.1.168.192.in-addr.arpa." type: "PTR" value: "ansible.example.org." state: present - name: Remove 1.1.168.192.in-addr.arpa. PTR community.general.nsupdate: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" server: "10.1.1.1" record: "1.1.168.192.in-addr.arpa." type: "PTR" state: absent ''' RETURN = ''' changed: description: If module has modified record returned: success type: str record: description: DNS record returned: success type: str sample: 'ansible' ttl: description: DNS record TTL returned: success type: int sample: 86400 type: description: DNS record type returned: success type: str sample: 'CNAME' value: description: DNS record value(s) returned: success type: list sample: '192.168.1.1' zone: description: DNS record zone returned: success type: str sample: 'example.org.' dns_rc: description: dnspython return code returned: always type: int sample: 4 dns_rc_str: description: dnspython return code (string representation) returned: always type: str sample: 'REFUSED' ''' import traceback from binascii import Error as binascii_error from socket import error as socket_error DNSPYTHON_IMP_ERR = None try: import dns.update import dns.query import dns.tsigkeyring import dns.message import dns.resolver HAVE_DNSPYTHON = True except ImportError: DNSPYTHON_IMP_ERR = traceback.format_exc() HAVE_DNSPYTHON = False from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.common.text.converters import to_native class RecordManager(object): def __init__(self, module): self.module = module if module.params['key_name']: try: self.keyring = dns.tsigkeyring.from_text({ module.params['key_name']: module.params['key_secret'] }) except TypeError: module.fail_json(msg='Missing key_secret') except binascii_error as e: module.fail_json(msg='TSIG key error: %s' % to_native(e)) else: self.keyring = None if module.params['key_algorithm'] == 'hmac-md5': self.algorithm = 'HMAC-MD5.SIG-ALG.REG.INT' else: self.algorithm = module.params['key_algorithm'] if module.params['zone'] is None: if module.params['record'][-1] != '.': self.module.fail_json(msg='record must be absolute when omitting zone parameter') self.zone = self.lookup_zone() else: self.zone = module.params['zone'] if self.zone[-1] != '.': self.zone += '.' if module.params['record'][-1] != '.': self.fqdn = module.params['record'] + '.' + self.zone else: self.fqdn = module.params['record'] if self.module.params['type'].lower() == 'txt' and self.module.params['value'] is not None: self.value = list(map(self.txt_helper, self.module.params['value'])) else: self.value = self.module.params['value'] self.dns_rc = 0 def txt_helper(self, entry): if entry[0] == '"' and entry[-1] == '"': return entry return '"{text}"'.format(text=entry) def lookup_zone(self): name = dns.name.from_text(self.module.params['record']) while True: query = dns.message.make_query(name, dns.rdatatype.SOA) if self.keyring: query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) try: if self.module.params['protocol'] == 'tcp': lookup = dns.query.tcp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) else: lookup = dns.query.udp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: self.module.fail_json(msg='TSIG update error (%s): %s' % (e.__class__.__name__, to_native(e))) except (socket_error, dns.exception.Timeout) as e: self.module.fail_json(msg='DNS server error: (%s): %s' % (e.__class__.__name__, to_native(e))) if lookup.rcode() in [dns.rcode.SERVFAIL, dns.rcode.REFUSED]: self.module.fail_json(msg='Zone lookup failure: \'%s\' will not respond to queries regarding \'%s\'.' % ( self.module.params['server'], self.module.params['record'])) # If the response contains an Answer SOA RR whose name matches the queried name, # this is the name of the zone in which the record needs to be inserted. for rr in lookup.answer: if rr.rdtype == dns.rdatatype.SOA and rr.name == name: return rr.name.to_text() # If the response contains an Authority SOA RR whose name is a subdomain of the queried name, # this SOA name is the zone in which the record needs to be inserted. for rr in lookup.authority: if rr.rdtype == dns.rdatatype.SOA and name.fullcompare(rr.name)[0] == dns.name.NAMERELN_SUBDOMAIN: return rr.name.to_text() try: name = name.parent() except dns.name.NoParent: self.module.fail_json(msg='Zone lookup of \'%s\' failed for unknown reason.' % (self.module.params['record'])) def __do_update(self, update): response = None try: if self.module.params['protocol'] == 'tcp': response = dns.query.tcp(update, self.module.params['server'], timeout=10, port=self.module.params['port']) else: response = dns.query.udp(update, self.module.params['server'], timeout=10, port=self.module.params['port']) except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: self.module.fail_json(msg='TSIG update error (%s): %s' % (e.__class__.__name__, to_native(e))) except (socket_error, dns.exception.Timeout) as e: self.module.fail_json(msg='DNS server error: (%s): %s' % (e.__class__.__name__, to_native(e))) return response def create_or_update_record(self): result = {'changed': False, 'failed': False} exists = self.record_exists() if exists in [0, 2]: if self.module.check_mode: self.module.exit_json(changed=True) if exists == 0: self.dns_rc = self.create_record() if self.dns_rc != 0: result['msg'] = "Failed to create DNS record (rc: %d)" % self.dns_rc elif exists == 2: self.dns_rc = self.modify_record() if self.dns_rc != 0: result['msg'] = "Failed to update DNS record (rc: %d)" % self.dns_rc if self.dns_rc != 0: result['failed'] = True else: result['changed'] = True else: result['changed'] = False return result def create_record(self): update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) for entry in self.value: try: update.add(self.module.params['record'], self.module.params['ttl'], self.module.params['type'], entry) except AttributeError: self.module.fail_json(msg='value needed when state=present') except dns.exception.SyntaxError: self.module.fail_json(msg='Invalid/malformed value') response = self.__do_update(update) return dns.message.Message.rcode(response) def modify_record(self): update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) if self.module.params['type'].upper() == 'NS': # When modifying a NS record, Bind9 silently refuses to delete all the NS entries for a zone: # > 09-May-2022 18:00:50.352 client @0x7fe7dd1f9568 192.168.1.3#45458/key rndc_ddns_ansible: # > updating zone 'lab/IN': attempt to delete all SOA or NS records ignored # https://gitlab.isc.org/isc-projects/bind9/-/blob/v9_18/lib/ns/update.c#L3304 # Let's perform dns inserts and updates first, deletes after. query = dns.message.make_query(self.module.params['record'], self.module.params['type']) if self.keyring: query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) try: if self.module.params['protocol'] == 'tcp': lookup = dns.query.tcp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) else: lookup = dns.query.udp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: self.module.fail_json(msg='TSIG update error (%s): %s' % (e.__class__.__name__, to_native(e))) except (socket_error, dns.exception.Timeout) as e: self.module.fail_json(msg='DNS server error: (%s): %s' % (e.__class__.__name__, to_native(e))) lookup_result = lookup.answer[0] if lookup.answer else lookup.authority[0] entries_to_remove = [n.to_text() for n in lookup_result.items if n.to_text() not in self.value] else: update.delete(self.module.params['record'], self.module.params['type']) for entry in self.value: try: update.add(self.module.params['record'], self.module.params['ttl'], self.module.params['type'], entry) except AttributeError: self.module.fail_json(msg='value needed when state=present') except dns.exception.SyntaxError: self.module.fail_json(msg='Invalid/malformed value') if self.module.params['type'].upper() == 'NS': for entry in entries_to_remove: update.delete(self.module.params['record'], self.module.params['type'], entry) response = self.__do_update(update) return dns.message.Message.rcode(response) def remove_record(self): result = {'changed': False, 'failed': False} if self.record_exists() == 0: return result # Check mode and record exists, declared fake change. if self.module.check_mode: self.module.exit_json(changed=True) update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) update.delete(self.module.params['record'], self.module.params['type']) response = self.__do_update(update) self.dns_rc = dns.message.Message.rcode(response) if self.dns_rc != 0: result['failed'] = True result['msg'] = "Failed to delete record (rc: %d)" % self.dns_rc else: result['changed'] = True return result def record_exists(self): update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) try: update.present(self.module.params['record'], self.module.params['type']) except dns.rdatatype.UnknownRdatatype as e: self.module.fail_json(msg='Record error: {0}'.format(to_native(e))) response = self.__do_update(update) self.dns_rc = dns.message.Message.rcode(response) if self.dns_rc == 0: if self.module.params['state'] == 'absent': return 1 for entry in self.value: try: update.present(self.module.params['record'], self.module.params['type'], entry) except AttributeError: self.module.fail_json(msg='value needed when state=present') except dns.exception.SyntaxError: self.module.fail_json(msg='Invalid/malformed value') response = self.__do_update(update) self.dns_rc = dns.message.Message.rcode(response) if self.dns_rc == 0: if self.ttl_changed(): return 2 else: return 1 else: return 2 else: return 0 def ttl_changed(self): query = dns.message.make_query(self.fqdn, self.module.params['type']) if self.keyring: query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) try: if self.module.params['protocol'] == 'tcp': lookup = dns.query.tcp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) else: lookup = dns.query.udp(query, self.module.params['server'], timeout=10, port=self.module.params['port']) except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: self.module.fail_json(msg='TSIG update error (%s): %s' % (e.__class__.__name__, to_native(e))) except (socket_error, dns.exception.Timeout) as e: self.module.fail_json(msg='DNS server error: (%s): %s' % (e.__class__.__name__, to_native(e))) if lookup.rcode() != dns.rcode.NOERROR: self.module.fail_json(msg='Failed to lookup TTL of existing matching record.') current_ttl = lookup.answer[0].ttl if lookup.answer else lookup.authority[0].ttl return current_ttl != self.module.params['ttl'] def main(): tsig_algs = ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'] module = AnsibleModule( argument_spec=dict( state=dict(required=False, default='present', choices=['present', 'absent'], type='str'), server=dict(required=True, type='str'), port=dict(required=False, default=53, type='int'), key_name=dict(required=False, type='str'), key_secret=dict(required=False, type='str', no_log=True), key_algorithm=dict(required=False, default='hmac-md5', choices=tsig_algs, type='str'), zone=dict(required=False, default=None, type='str'), record=dict(required=True, type='str'), type=dict(required=False, default='A', type='str'), ttl=dict(required=False, default=3600, type='int'), value=dict(required=False, default=None, type='list', elements='str'), protocol=dict(required=False, default='tcp', choices=['tcp', 'udp'], type='str') ), supports_check_mode=True ) if not HAVE_DNSPYTHON: module.fail_json(msg=missing_required_lib('dnspython'), exception=DNSPYTHON_IMP_ERR) if len(module.params["record"]) == 0: module.fail_json(msg='record cannot be empty.') record = RecordManager(module) result = {} if module.params["state"] == 'absent': result = record.remove_record() elif module.params["state"] == 'present': result = record.create_or_update_record() result['dns_rc'] = record.dns_rc result['dns_rc_str'] = dns.rcode.to_text(record.dns_rc) if result['failed']: module.fail_json(**result) else: result['record'] = dict(zone=record.zone, record=module.params['record'], type=module.params['type'], ttl=module.params['ttl'], value=record.value) module.exit_json(**result) if __name__ == '__main__': main()