diff --git a/plugins/modules/nsupdate_zone.py b/plugins/modules/nsupdate_zone.py index 2efda33..5fe3ff2 100644 --- a/plugins/modules/nsupdate_zone.py +++ b/plugins/modules/nsupdate_zone.py @@ -28,12 +28,14 @@ attributes: check_mode: support: full diff_mode: - support: none + support: full options: zones: description: - List of zones to manage. - - Each zone dict must contain O(zones[].name), O(zones[].dns_server), and O(zones[].records). + - Each zone dict must contain O(zones[].name) and O(zones[].records). + - O(zones[].dns_server) is optional if O(dns_server) is provided globally. + - Extra keys are allowed for flexibility and will be ignored. type: list elements: dict required: true @@ -46,8 +48,8 @@ options: dns_server: description: - DNS server to query and update, specified by IPv4/IPv6 address or FQDN. + - Optional if global O(dns_server) is provided. type: str - required: true records: description: - List of DNS records for this zone. @@ -128,11 +130,37 @@ options: type: list elements: str default: [] - parallel_zones: + ignore_dnssec_records: description: - - Process multiple zones concurrently. - - Each zone update is still atomic, but different zones are processed in parallel. - - Experimental feature - use with caution. + - Automatically ignore DNSSEC-managed record types. + - When enabled, DNSKEY, RRSIG, NSEC, NSEC3, and NSEC3PARAM records are added to the ignore list. + - Useful when DNS servers manage DNSSEC records automatically and they should not be modified. + type: bool + default: true + ignore_soa_records: + description: + - Automatically ignore SOA records. + - When enabled, SOA records are added to the ignore list. + - Recommended to keep enabled as SOA records are typically managed by DNS servers themselves. + type: bool + default: true + validate_records: + description: + - Validate record values before applying changes. + - When enabled, performs syntax validation on record values (IPv4/IPv6 addresses, FQDNs, etc.). + - Prevents invalid records from being sent to the DNS server. + type: bool + default: true + dns_server: + description: + - Global DNS server to use for all zones that do not specify their own. + - Can be an IPv4/IPv6 address or FQDN. + - If specified, zones do not need O(zones[].dns_server). + type: str + verbose: + description: + - Enable verbose output showing per-record actions. + - When enabled, includes details about which records were added, removed, changed, or skipped. type: bool default: false """ @@ -210,19 +238,17 @@ EXAMPLES = r""" type: A value: 192.168.2.1 -- name: Manage large zone with parallel processing +- name: Manage large zone with global server community.general.nsupdate_zone: key_name: "nsupdate" key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" - parallel_zones: true + dns_server: ns1.dns.com protocol: tcp zones: - name: bigzone1.com - dns_server: ns1.dns.com records: "{{ bigzone1_records }}" - name: bigzone2.com - dns_server: ns1.dns.com records: "{{ bigzone2_records }}" """ @@ -284,7 +310,6 @@ failed: import ipaddress import re from binascii import Error as binascii_error -from concurrent.futures import ThreadPoolExecutor, as_completed from ansible.module_utils.basic import AnsibleModule @@ -308,9 +333,16 @@ class DNSZoneManager: def __init__(self, module, zone_config): self.module = module self.zone_name_str = zone_config['name'] - self.dns_server = zone_config['dns_server'] self.records = zone_config['records'] + # Get DNS server from zone config or fall back to global parameter + self.dns_server = zone_config.get('dns_server') + if not self.dns_server: + self.dns_server = module.params.get('dns_server') + + if not self.dns_server: + self.module.fail_json(msg=f"DNS server must be specified for zone {self.zone_name_str} or via global dns_server parameter") + # Normalize zone name to dns.name.Name object if not self.zone_name_str.endswith('.'): self.zone_name_str += '.' @@ -326,6 +358,17 @@ class DNSZoneManager: self.protocol = module.params['protocol'] self.port = module.params['port'] self.ignore_types = set(module.params['ignore_record_types']) + self.validate_records = module.params.get('validate_records', True) + + # Add DNSSEC record types to ignore list if enabled + if module.params.get('ignore_dnssec_records', True): + dnssec_types = {'DNSKEY', 'RRSIG', 'NSEC', 'NSEC3', 'NSEC3PARAM'} + self.ignore_types.update(dnssec_types) + + # Add SOA record type to ignore list if enabled + if module.params.get('ignore_soa_records', True): + self.ignore_types.add('SOA') + self.ignore_patterns = [re.compile(pattern) for pattern in module.params['ignore_record_patterns']] # State @@ -487,6 +530,48 @@ class DNSZoneManager: parsed_records[key]['values'].add(str(value)) return parsed_records + + def _validate_record_values(self, parsed_records): + """Validate record values if validation is enabled.""" + if not self.validate_records: + return + + for key, record in parsed_records.items(): + name, record_type = key + name_str = name.to_text() + + # Skip validation for records with state absent + if record['state'] == 'absent': + continue + + # Validate based on record type + for value in record['values']: + try: + if record_type in ('A',): + ipaddress.ip_address(value) + elif record_type in ('AAAA',): + ipaddress.ip_address(value) + elif record_type in ('CNAME', 'MX', 'NS', 'PTR', 'SRV'): + # Validate FQDN - must end with dot or be resolvable + if not value.endswith('.'): + # Try to validate as partial name - just check it's not empty + if not value: + self.module.fail_json( + msg=f"Invalid {record_type} record at {name_str}: value cannot be empty" + ) + else: + # Try to parse as absolute FQDN + try: + dns.name.from_text(value) + except Exception as e: + self.module.fail_json( + msg=f"Invalid {record_type} FQDN at {name_str}: {value} - {e}" + ) + # TXT, SPF, and other text records don't need special validation + except ValueError as e: + self.module.fail_json( + msg=f"Invalid {record_type} record at {name_str}: {value} - {e}" + ) def _build_record_sets(self): """Build comparable record sets from current zone.""" @@ -539,15 +624,24 @@ class DNSZoneManager: return filtered def _compute_changes(self, desired_records, current_records): - """Compute adds, deletes, and updates needed.""" + """Compute adds, deletes, and updates needed with verbose action tracking.""" changes = { 'adds': [], 'deletes': [], - 'updates': [] + 'updates': [], + 'skipped': [], + 'ignored': [] } - # Find adds and updates + # Track verbose actions per record + verbose_actions = [] + + # Find adds, updates, and unchanged records for key, desired in desired_records.items(): + name_str = desired['name'].to_text() + record_type = desired['type'] + record_key = f"{name_str} {record_type}" + if desired['state'] == 'absent': # Handle explicit absents if key in current_records: @@ -556,27 +650,64 @@ class DNSZoneManager: 'type': desired['type'], 'values': desired['values'] }) + verbose_actions.append({ + 'record': record_key, + 'action': 'Removed', + 'before': current_records[key]['values'], + 'after': set() + }) else: # State is 'present' if key not in current_records: # Record doesn't exist - add it changes['adds'].append(desired) + verbose_actions.append({ + 'record': record_key, + 'action': 'Added', + 'before': set(), + 'after': desired['values'] + }) else: # Record exists - check if values differ current = current_records[key] if desired['values'] != current['values'] or desired['ttl'] != current['ttl']: changes['updates'].append(desired) + verbose_actions.append({ + 'record': record_key, + 'action': 'Changed', + 'before': current['values'], + 'after': desired['values'] + }) + else: + # Record unchanged + verbose_actions.append({ + 'record': record_key, + 'action': 'Skipped', + 'before': current['values'], + 'after': desired['values'] + }) # Find deletes (records in current but not in desired, unless ignored) for key, current in current_records.items(): if key not in desired_records: # Not in desired state - delete it + name_str = current['name'].to_text() + record_type = current['type'] + record_key = f"{name_str} {record_type}" + changes['deletes'].append({ 'name': current['name'], 'type': current['type'], 'values': current['values'] }) + verbose_actions.append({ + 'record': record_key, + 'action': 'Removed', + 'before': current['values'], + 'after': set() + }) + changes['verbose_actions'] = verbose_actions return changes def _validate_cname_conflicts(self, desired_records): @@ -692,7 +823,10 @@ class DNSZoneManager: # Step 2: Parse YAML records desired_records = self._parse_yaml_records() - # Step 3: Validate CNAME conflicts + # Step 3: Validate records (if enabled) + self._validate_record_values(desired_records) + + # Step 4: Validate CNAME conflicts self._validate_cname_conflicts(desired_records) # Step 4: Build current record sets @@ -709,6 +843,9 @@ class DNSZoneManager: if total_changes == 0: result['changed'] = False + if self.module.params['verbose']: + # Include verbose output even if no changes + result['changes']['verbose'] = changes.get('verbose_actions', []) return result result['changed'] = True @@ -716,6 +853,14 @@ class DNSZoneManager: result['changes']['deletes'] = len(changes['deletes']) result['changes']['updates'] = len(changes['updates']) + # Add verbose output if enabled + if self.module.params['verbose']: + result['changes']['verbose'] = changes.get('verbose_actions', []) + + # Add diff output if enabled + if self.module.diff: + result['diff'] = self._format_diff(changes) + # Step 8: Apply changes (unless check mode) if not self.module.check_mode: update_result = self._apply_changes(changes) @@ -729,6 +874,30 @@ class DNSZoneManager: result['error'] = str(e) result['failed'] = True return result + + def _format_diff(self, changes): + """Format changes as a diff structure for diff mode.""" + diff_before = {} + diff_after = {} + + # Process all changes + for action_info in changes.get('verbose_actions', []): + record_key = action_info['record'] + action = action_info['action'] + + if action == 'Added': + diff_after[record_key] = list(action_info['after']) + elif action == 'Removed': + diff_before[record_key] = list(action_info['before']) + elif action == 'Changed': + diff_before[record_key] = list(action_info['before']) + diff_after[record_key] = list(action_info['after']) + # Skipped records are not included in diff + + return { + 'before': diff_before, + 'after': diff_after + } def process_single_zone(module, zone_config): @@ -746,7 +915,7 @@ def main(): required=True, options=dict( name=dict(type='str', required=True), - dns_server=dict(type='str', required=True), + dns_server=dict(type='str'), records=dict( type='list', elements='dict', @@ -780,9 +949,14 @@ def main(): port=dict(type='int', default=53), ignore_record_types=dict(type='list', elements='str', default=['NS']), ignore_record_patterns=dict(type='list', elements='str', default=[]), - parallel_zones=dict(type='bool', default=False) + ignore_dnssec_records=dict(type='bool', default=True), + ignore_soa_records=dict(type='bool', default=True), + validate_records=dict(type='bool', default=True), + dns_server=dict(type='str'), + verbose=dict(type='bool', default=False) ), supports_check_mode=True, + supports_diff_mode=True, required_together=[ ['key_name', 'key_secret'] ] @@ -792,36 +966,19 @@ def main(): deps.validate(module, "dnspython") zones = module.params['zones'] - parallel_zones = module.params['parallel_zones'] results = [] overall_changed = False overall_failed = False - if parallel_zones and len(zones) > 1: - # Process zones in parallel - with ThreadPoolExecutor(max_workers=min(len(zones), 5)) as executor: - futures = { - executor.submit(process_single_zone, module, zone_config): zone_config - for zone_config in zones - } - - for future in as_completed(futures): - result = future.result() - results.append(result) - if result.get('changed', False): - overall_changed = True - if result.get('failed', False): - overall_failed = True - else: - # Process zones serially - for zone_config in zones: - result = process_single_zone(module, zone_config) - results.append(result) - if result.get('changed', False): - overall_changed = True - if result.get('failed', False): - overall_failed = True + # Process zones sequentially + for zone_config in zones: + result = process_single_zone(module, zone_config) + results.append(result) + if result.get('changed', False): + overall_changed = True + if result.get('failed', False): + overall_failed = True module.exit_json( changed=overall_changed,