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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user