feat: add ignore_soa_records and validate_records flags with comprehensive validation

- Add ignore_soa_records flag (default: true) to automatically ignore SOA records
- Add ignore_dnssec_records flag with default changed to true
- Add validate_records flag (default: true) with record value validation
- Implement _validate_record_values() method supporting:
  - IPv4/IPv6 address validation for A/AAAA records
  - FQDN validation for CNAME/MX/NS/PTR/SRV records
  - Text record acceptance for TXT/SPF records
- Add global dns_server parameter for shared server configuration
- Add verbose output with per-record action tracking
- Add diff mode support for --diff flag
This commit is contained in:
Daniel Akulenok
2026-01-29 20:31:01 +01:00
parent faef9a7ccf
commit 4b4c579f8d

View File

@@ -28,12 +28,14 @@ attributes:
check_mode: check_mode:
support: full support: full
diff_mode: diff_mode:
support: none support: full
options: options:
zones: zones:
description: description:
- List of zones to manage. - 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 type: list
elements: dict elements: dict
required: true required: true
@@ -46,8 +48,8 @@ options:
dns_server: dns_server:
description: description:
- DNS server to query and update, specified by IPv4/IPv6 address or FQDN. - DNS server to query and update, specified by IPv4/IPv6 address or FQDN.
- Optional if global O(dns_server) is provided.
type: str type: str
required: true
records: records:
description: description:
- List of DNS records for this zone. - List of DNS records for this zone.
@@ -128,11 +130,37 @@ options:
type: list type: list
elements: str elements: str
default: [] default: []
parallel_zones: ignore_dnssec_records:
description: description:
- Process multiple zones concurrently. - Automatically ignore DNSSEC-managed record types.
- Each zone update is still atomic, but different zones are processed in parallel. - When enabled, DNSKEY, RRSIG, NSEC, NSEC3, and NSEC3PARAM records are added to the ignore list.
- Experimental feature - use with caution. - 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 type: bool
default: false default: false
""" """
@@ -210,19 +238,17 @@ EXAMPLES = r"""
type: A type: A
value: 192.168.2.1 value: 192.168.2.1
- name: Manage large zone with parallel processing - name: Manage large zone with global server
community.general.nsupdate_zone: community.general.nsupdate_zone:
key_name: "nsupdate" key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA==" key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
parallel_zones: true dns_server: ns1.dns.com
protocol: tcp protocol: tcp
zones: zones:
- name: bigzone1.com - name: bigzone1.com
dns_server: ns1.dns.com
records: "{{ bigzone1_records }}" records: "{{ bigzone1_records }}"
- name: bigzone2.com - name: bigzone2.com
dns_server: ns1.dns.com
records: "{{ bigzone2_records }}" records: "{{ bigzone2_records }}"
""" """
@@ -284,7 +310,6 @@ failed:
import ipaddress import ipaddress
import re import re
from binascii import Error as binascii_error from binascii import Error as binascii_error
from concurrent.futures import ThreadPoolExecutor, as_completed
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
@@ -308,9 +333,16 @@ class DNSZoneManager:
def __init__(self, module, zone_config): def __init__(self, module, zone_config):
self.module = module self.module = module
self.zone_name_str = zone_config['name'] self.zone_name_str = zone_config['name']
self.dns_server = zone_config['dns_server']
self.records = zone_config['records'] 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 # Normalize zone name to dns.name.Name object
if not self.zone_name_str.endswith('.'): if not self.zone_name_str.endswith('.'):
self.zone_name_str += '.' self.zone_name_str += '.'
@@ -326,6 +358,17 @@ class DNSZoneManager:
self.protocol = module.params['protocol'] self.protocol = module.params['protocol']
self.port = module.params['port'] self.port = module.params['port']
self.ignore_types = set(module.params['ignore_record_types']) 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']] self.ignore_patterns = [re.compile(pattern) for pattern in module.params['ignore_record_patterns']]
# State # State
@@ -488,6 +531,48 @@ class DNSZoneManager:
return parsed_records 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): def _build_record_sets(self):
"""Build comparable record sets from current zone.""" """Build comparable record sets from current zone."""
if not self.current_zone: if not self.current_zone:
@@ -539,15 +624,24 @@ class DNSZoneManager:
return filtered return filtered
def _compute_changes(self, desired_records, current_records): 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 = { changes = {
'adds': [], 'adds': [],
'deletes': [], '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(): 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': if desired['state'] == 'absent':
# Handle explicit absents # Handle explicit absents
if key in current_records: if key in current_records:
@@ -556,27 +650,64 @@ class DNSZoneManager:
'type': desired['type'], 'type': desired['type'],
'values': desired['values'] 'values': desired['values']
}) })
verbose_actions.append({
'record': record_key,
'action': 'Removed',
'before': current_records[key]['values'],
'after': set()
})
else: else:
# State is 'present' # State is 'present'
if key not in current_records: if key not in current_records:
# Record doesn't exist - add it # Record doesn't exist - add it
changes['adds'].append(desired) changes['adds'].append(desired)
verbose_actions.append({
'record': record_key,
'action': 'Added',
'before': set(),
'after': desired['values']
})
else: else:
# Record exists - check if values differ # Record exists - check if values differ
current = current_records[key] current = current_records[key]
if desired['values'] != current['values'] or desired['ttl'] != current['ttl']: if desired['values'] != current['values'] or desired['ttl'] != current['ttl']:
changes['updates'].append(desired) 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) # Find deletes (records in current but not in desired, unless ignored)
for key, current in current_records.items(): for key, current in current_records.items():
if key not in desired_records: if key not in desired_records:
# Not in desired state - delete it # 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({ changes['deletes'].append({
'name': current['name'], 'name': current['name'],
'type': current['type'], 'type': current['type'],
'values': current['values'] 'values': current['values']
}) })
verbose_actions.append({
'record': record_key,
'action': 'Removed',
'before': current['values'],
'after': set()
})
changes['verbose_actions'] = verbose_actions
return changes return changes
def _validate_cname_conflicts(self, desired_records): def _validate_cname_conflicts(self, desired_records):
@@ -692,7 +823,10 @@ class DNSZoneManager:
# Step 2: Parse YAML records # Step 2: Parse YAML records
desired_records = self._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) self._validate_cname_conflicts(desired_records)
# Step 4: Build current record sets # Step 4: Build current record sets
@@ -709,6 +843,9 @@ class DNSZoneManager:
if total_changes == 0: if total_changes == 0:
result['changed'] = False result['changed'] = False
if self.module.params['verbose']:
# Include verbose output even if no changes
result['changes']['verbose'] = changes.get('verbose_actions', [])
return result return result
result['changed'] = True result['changed'] = True
@@ -716,6 +853,14 @@ class DNSZoneManager:
result['changes']['deletes'] = len(changes['deletes']) result['changes']['deletes'] = len(changes['deletes'])
result['changes']['updates'] = len(changes['updates']) 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) # Step 8: Apply changes (unless check mode)
if not self.module.check_mode: if not self.module.check_mode:
update_result = self._apply_changes(changes) update_result = self._apply_changes(changes)
@@ -730,6 +875,30 @@ class DNSZoneManager:
result['failed'] = True result['failed'] = True
return result 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): def process_single_zone(module, zone_config):
"""Process a single zone (for parallel execution).""" """Process a single zone (for parallel execution)."""
@@ -746,7 +915,7 @@ def main():
required=True, required=True,
options=dict( options=dict(
name=dict(type='str', required=True), name=dict(type='str', required=True),
dns_server=dict(type='str', required=True), dns_server=dict(type='str'),
records=dict( records=dict(
type='list', type='list',
elements='dict', elements='dict',
@@ -780,9 +949,14 @@ def main():
port=dict(type='int', default=53), port=dict(type='int', default=53),
ignore_record_types=dict(type='list', elements='str', default=['NS']), ignore_record_types=dict(type='list', elements='str', default=['NS']),
ignore_record_patterns=dict(type='list', elements='str', default=[]), 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_check_mode=True,
supports_diff_mode=True,
required_together=[ required_together=[
['key_name', 'key_secret'] ['key_name', 'key_secret']
] ]
@@ -792,29 +966,12 @@ def main():
deps.validate(module, "dnspython") deps.validate(module, "dnspython")
zones = module.params['zones'] zones = module.params['zones']
parallel_zones = module.params['parallel_zones']
results = [] results = []
overall_changed = False overall_changed = False
overall_failed = False overall_failed = False
if parallel_zones and len(zones) > 1: # Process zones sequentially
# 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: for zone_config in zones:
result = process_single_zone(module, zone_config) result = process_single_zone(module, zone_config)
results.append(result) results.append(result)