Files
valid.nsupdate_zone/plugins/modules/nsupdate_zone.py
Daniel Akulenok 0142f806c9 First commit
2026-01-29 11:05:17 +01:00

835 lines
28 KiB
Python

#!/usr/bin/python
# Copyright (c) 2026, Dan Kercher
#
# 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 annotations
DOCUMENTATION = r"""
module: nsupdate_zone
short_description: Manage complete DNS zones using AXFR and atomic batched updates
description:
- Fetch complete zone state via AXFR zone transfer.
- Compare current zone state with desired state specified in YAML.
- Compute minimal required changes (adds, deletes, updates).
- Apply all changes atomically using batched DNS UPDATE messages per RFC 2136.
- Support for configurable ignore patterns (e.g., NS records, ACME challenges).
- Efficient management of large zones with hundreds or thousands of records.
version_added: 10.7.0
requirements:
- dnspython
author: "Dan Kercher"
extends_documentation_fragment:
- community.general.attributes
attributes:
check_mode:
support: full
diff_mode:
support: none
options:
zones:
description:
- List of zones to manage.
- Each zone dict must contain O(zones[].name), O(zones[].dns_server), and O(zones[].records).
type: list
elements: dict
required: true
suboptions:
name:
description:
- Zone name (e.g., V(example.com)).
type: str
required: true
dns_server:
description:
- DNS server to query and update, specified by IPv4/IPv6 address or FQDN.
type: str
required: true
records:
description:
- List of DNS records for this zone.
type: list
elements: dict
required: true
suboptions:
record:
description:
- Record name within the zone.
- If the name ends with a dot (.), it is treated as a FQDN.
- Otherwise, the zone name is automatically appended.
- Use the zone FQDN with trailing dot to reference the zone apex (e.g., V(example.com.)).
type: str
required: true
type:
description:
- DNS record type.
type: str
required: true
value:
description:
- Record value(s).
- Can be a single string or a list of strings for multiple values.
- For records with multiple fields (MX, SRV, etc.), separate fields with spaces.
type: raw
required: true
ttl:
description:
- Record TTL in seconds.
- If omitted, uses the zone SOA MINIMUM value.
type: int
state:
description:
- Whether this record should be present or absent.
type: str
choices: ['present', 'absent']
default: 'present'
key_name:
description:
- TSIG key name for authentication.
type: str
key_secret:
description:
- TSIG key secret, associated with O(key_name).
type: str
key_algorithm:
description:
- 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
protocol:
description:
- Transport protocol (TCP or UDP).
- TCP is recommended for large zones and provides better reliability.
default: 'tcp'
choices: ['tcp', 'udp']
type: str
port:
description:
- TCP/UDP port for DNS server connection.
default: 53
type: int
ignore_record_types:
description:
- List of record types to ignore during comparison.
- Records of these types in the current zone will not trigger deletions.
- Useful for preserving server-managed records like NS at zone apex.
type: list
elements: str
default: ['NS']
ignore_record_patterns:
description:
- List of regex patterns for record names to ignore.
- Records matching these patterns will not be compared or modified.
- Useful for ignoring ACME challenge records (e.g., V(^_acme-challenge\\..*)).
type: list
elements: str
default: []
parallel_zones:
description:
- Process multiple zones concurrently.
- Each zone update is still atomic, but different zones are processed in parallel.
- Experimental feature - use with caution.
type: bool
default: false
"""
EXAMPLES = r"""
- name: Manage a complete DNS zone
community.general.nsupdate_zone:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
zones:
- name: example.com
dns_server: ns1.example.com
records:
# Zone apex A record
- record: 'example.com.'
type: A
value: 192.168.1.1
ttl: 3600
# Subdomain with multiple A records
- record: www
type: A
value:
- 192.168.1.10
- 192.168.1.11
ttl: 300
# CNAME record
- record: blog
type: CNAME
value: www
# MX records with priority
- record: 'example.com.'
type: MX
value:
- "10 mail1.example.com."
- "20 mail2.example.com."
# TXT records
- record: 'example.com.'
type: TXT
value:
- "v=spf1 mx a include:_spf.google.com ~all"
- "google-site-verification=abc123"
# Remove a record
- record: old-server
type: A
value: 192.168.1.99
state: absent
- name: Manage multiple zones with ignore patterns
community.general.nsupdate_zone:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
ignore_record_types:
- NS
- SOA
ignore_record_patterns:
- '^_acme-challenge\..*'
- '^_dnsauth\..*'
zones:
- name: example.com
dns_server: 10.1.1.1
records:
- record: 'example.com.'
type: A
value: 192.168.1.1
- name: example.org
dns_server: 10.1.1.1
records:
- record: 'example.org.'
type: A
value: 192.168.2.1
- name: Manage large zone with parallel processing
community.general.nsupdate_zone:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
parallel_zones: true
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 }}"
"""
RETURN = r"""
results:
description: Results for each zone processed.
returned: always
type: list
elements: dict
contains:
zone:
description: Zone name.
type: str
sample: 'example.com'
changed:
description: Whether the zone was modified.
type: bool
sample: true
dns_rc:
description: DNS response code.
type: int
sample: 0
dns_rc_str:
description: DNS response code (string representation).
type: str
sample: 'NOERROR'
changes:
description: Summary of changes applied.
type: dict
contains:
adds:
description: Number of records added.
type: int
sample: 5
deletes:
description: Number of records deleted.
type: int
sample: 2
updates:
description: Number of records updated.
type: int
sample: 3
error:
description: Error message if zone processing failed.
type: str
sample: 'AXFR failed: connection timeout'
changed:
description: Whether any zone was modified.
type: bool
returned: always
sample: true
failed:
description: Whether any zone processing failed.
type: bool
returned: always
sample: false
"""
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
from ansible_collections.valid.nsupdate_zone.plugins.module_utils import deps
with deps.declare("dnspython", url="https://github.com/rthalley/dnspython"):
import dns.exception
import dns.message
import dns.name
import dns.query
import dns.rcode
import dns.rdatatype
import dns.resolver
import dns.tsig
import dns.tsigkeyring
import dns.update
import dns.zone
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']
# Normalize zone name to dns.name.Name object
if not self.zone_name_str.endswith('.'):
self.zone_name_str += '.'
self.zone_name = dns.name.from_text(self.zone_name_str)
# Resolve server address
self.server_ips = self._resolve_server() or []
# Setup authentication
self._setup_authentication()
# Configuration
self.protocol = module.params['protocol']
self.port = module.params['port']
self.ignore_types = set(module.params['ignore_record_types'])
self.ignore_patterns = [re.compile(pattern) for pattern in module.params['ignore_record_patterns']]
# State
self.current_zone = None
self.soa_minimum_ttl = 3600 # Default, will be updated from SOA
def _resolve_server(self):
"""Resolve DNS server FQDN to IP addresses."""
server = self.dns_server
# Check if it's already an IP address
try:
ipaddress.ip_address(server)
return [server]
except ValueError:
pass
# Resolve FQDN to IP addresses
try:
resolver = dns.resolver.Resolver()
ips = []
# Try A records
try:
for rdata in resolver.resolve(server, 'A'):
ips.append(str(rdata))
except Exception:
pass
# Try AAAA records
try:
for rdata in resolver.resolve(server, 'AAAA'):
ips.append(str(rdata))
except Exception:
pass
if not ips:
self.module.fail_json(msg=f"Cannot resolve DNS server {server}")
return ips
except Exception as e:
self.module.fail_json(msg=f"Failed to resolve DNS server {server}: {e}")
def _setup_authentication(self):
"""Setup TSIG authentication if configured."""
key_name = self.module.params['key_name']
key_secret = self.module.params['key_secret']
key_algorithm = self.module.params['key_algorithm']
if key_name and key_secret:
if key_algorithm == "hmac-md5":
self.algorithm = "HMAC-MD5.SIG-ALG.REG.INT"
else:
self.algorithm = key_algorithm
try:
self.keyring = dns.tsigkeyring.from_text({key_name: key_secret})
self.keyname = key_name
except TypeError:
self.module.fail_json(msg="Missing key_secret")
except binascii_error as e:
self.module.fail_json(msg=f"TSIG key error: {e}")
else:
self.keyring = None
self.keyname = None
self.algorithm = None
def _perform_axfr(self):
"""Perform AXFR zone transfer to get current zone state."""
for server_ip in self.server_ips:
try:
if self.keyring:
zone = dns.zone.from_xfr(
dns.query.xfr(
server_ip,
self.zone_name,
port=self.port,
keyring=self.keyring,
keyname=self.keyname,
keyalgorithm=self.algorithm
)
)
else:
zone = dns.zone.from_xfr(
dns.query.xfr(
server_ip,
self.zone_name,
port=self.port
)
)
# Extract SOA minimum TTL
soa_rdataset = zone.find_rdataset('@', dns.rdatatype.SOA)
if soa_rdataset:
for rdata in soa_rdataset:
self.soa_minimum_ttl = rdata.minimum
break
self.current_zone = zone
return
except dns.exception.FormError as e:
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: malformed response: {e}")
except dns.tsig.PeerBadKey as e:
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: bad TSIG key: {e}")
except dns.tsig.PeerBadSignature as e:
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: bad TSIG signature: {e}")
except Exception as e:
# Try next server if available
if server_ip == self.server_ips[-1]:
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: {e}")
continue
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: all servers failed")
def _parse_yaml_records(self):
"""Parse and normalize YAML records into a comparable format."""
parsed_records = {}
for record_config in self.records:
record_name = record_config['record']
record_type = record_config['type'].upper()
record_values = record_config['value']
record_ttl = record_config.get('ttl', self.soa_minimum_ttl)
record_state = record_config.get('state', 'present')
# Normalize record name
if record_name.endswith('.'):
# Absolute FQDN
fqdn = record_name
else:
# Relative name - append zone
fqdn = f"{record_name}.{self.zone_name_str}"
# Normalize to dns.name.Name
try:
name_obj = dns.name.from_text(fqdn)
except Exception as e:
self.module.fail_json(msg=f"Invalid record name {record_name}: {e}")
continue # For type checker, though fail_json exits
# Normalize values to list
if not isinstance(record_values, list):
record_values = [record_values]
# Store in parsed_records
key = (name_obj, record_type)
if key not in parsed_records:
parsed_records[key] = {
'name': name_obj,
'type': record_type,
'ttl': record_ttl,
'values': set(),
'state': record_state
}
# Add values
for value in record_values:
parsed_records[key]['values'].add(str(value))
return parsed_records
def _build_record_sets(self):
"""Build comparable record sets from current zone."""
if not self.current_zone:
return {}
record_sets = {}
for name, node in self.current_zone.items():
abs_name = name.derelativize(self.zone_name)
for rdataset in node.rdatasets:
record_type = dns.rdatatype.to_text(rdataset.rdtype)
key = (abs_name, record_type)
if key not in record_sets:
record_sets[key] = {
'name': abs_name,
'type': record_type,
'ttl': rdataset.ttl,
'values': set()
}
for rdata in rdataset:
record_sets[key]['values'].add(rdata.to_text())
return record_sets
def _should_ignore_record(self, name, record_type):
"""Check if a record should be ignored based on configured patterns."""
# Check type ignore
if record_type in self.ignore_types:
return True
# Check pattern ignore
name_str = name.to_text()
for pattern in self.ignore_patterns:
if pattern.match(name_str):
return True
return False
def _filter_ignored_records(self, record_sets):
"""Remove ignored records from record sets."""
filtered = {}
for key, record_data in record_sets.items():
name, record_type = key
if not self._should_ignore_record(name, record_type):
filtered[key] = record_data
return filtered
def _compute_changes(self, desired_records, current_records):
"""Compute adds, deletes, and updates needed."""
changes = {
'adds': [],
'deletes': [],
'updates': []
}
# Find adds and updates
for key, desired in desired_records.items():
if desired['state'] == 'absent':
# Handle explicit absents
if key in current_records:
changes['deletes'].append({
'name': desired['name'],
'type': desired['type'],
'values': desired['values']
})
else:
# State is 'present'
if key not in current_records:
# Record doesn't exist - add it
changes['adds'].append(desired)
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)
# 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
changes['deletes'].append({
'name': current['name'],
'type': current['type'],
'values': current['values']
})
return changes
def _validate_cname_conflicts(self, desired_records):
"""Validate that CNAME records don't conflict with other record types."""
name_types = {}
for key, record in desired_records.items():
if record['state'] != 'present':
continue
name, record_type = key
if name not in name_types:
name_types[name] = set()
name_types[name].add(record_type)
for name, types in name_types.items():
if 'CNAME' in types and len(types) > 1:
self.module.fail_json(
msg=f"CNAME conflict at {name.to_text()}: cannot have CNAME with other record types {types}"
)
def _apply_changes(self, changes):
"""Apply changes using a single batched UPDATE message."""
# Create update message
update = dns.update.Update(
self.zone_name,
keyring=self.keyring,
keyname=self.keyname,
keyalgorithm=self.algorithm
)
# Add all delete operations first
for delete_op in changes['deletes']:
name = delete_op['name']
record_type = delete_op['type']
# Delete the entire RRset
update.delete(name, record_type)
# Add all update operations (delete then add)
for update_op in changes['updates']:
name = update_op['name']
record_type = update_op['type']
ttl = update_op['ttl']
# Delete existing
update.delete(name, record_type)
# Add new values
for value in update_op['values']:
update.add(name, ttl, record_type, value)
# Add all add operations
for add_op in changes['adds']:
name = add_op['name']
record_type = add_op['type']
ttl = add_op['ttl']
for value in add_op['values']:
update.add(name, ttl, record_type, value)
# Send update message
for server_ip in self.server_ips:
try:
if self.protocol == 'tcp':
response = dns.query.tcp(update, server_ip, port=self.port, timeout=10)
else:
response = dns.query.udp(update, server_ip, port=self.port, timeout=10)
rcode = response.rcode()
rcode_str = dns.rcode.to_text(rcode)
if rcode != dns.rcode.NOERROR:
self.module.fail_json(
msg=f"DNS UPDATE failed for {self.zone_name_str}: {rcode_str}",
dns_rc=rcode,
dns_rc_str=rcode_str
)
return {
'dns_rc': rcode,
'dns_rc_str': rcode_str
}
except dns.tsig.PeerBadKey as e:
self.module.fail_json(msg=f"UPDATE failed: bad TSIG key: {e}")
except dns.tsig.PeerBadSignature as e:
self.module.fail_json(msg=f"UPDATE failed: bad TSIG signature: {e}")
except Exception as e:
if server_ip == self.server_ips[-1]:
self.module.fail_json(msg=f"UPDATE failed for {self.zone_name_str}: {e}")
continue
self.module.fail_json(msg=f"UPDATE failed for {self.zone_name_str}: all servers failed")
def process_zone(self):
"""Main processing logic for a single zone."""
result = {
'zone': self.zone_name_str,
'changed': False,
'dns_rc': 0,
'dns_rc_str': 'NOERROR',
'changes': {
'adds': 0,
'deletes': 0,
'updates': 0
}
}
try:
# Step 1: Perform AXFR
self._perform_axfr()
# Step 2: Parse YAML records
desired_records = self._parse_yaml_records()
# Step 3: Validate CNAME conflicts
self._validate_cname_conflicts(desired_records)
# Step 4: Build current record sets
current_records = self._build_record_sets()
# Step 5: Filter ignored records
current_records = self._filter_ignored_records(current_records)
# Step 6: Compute changes
changes = self._compute_changes(desired_records, current_records)
# Step 7: Determine if changes needed
total_changes = len(changes['adds']) + len(changes['deletes']) + len(changes['updates'])
if total_changes == 0:
result['changed'] = False
return result
result['changed'] = True
result['changes']['adds'] = len(changes['adds'])
result['changes']['deletes'] = len(changes['deletes'])
result['changes']['updates'] = len(changes['updates'])
# Step 8: Apply changes (unless check mode)
if not self.module.check_mode:
update_result = self._apply_changes(changes)
if update_result:
result['dns_rc'] = update_result['dns_rc']
result['dns_rc_str'] = update_result['dns_rc_str']
return result
except Exception as e:
result['error'] = str(e)
result['failed'] = True
return result
def process_single_zone(module, zone_config):
"""Process a single zone (for parallel execution)."""
manager = DNSZoneManager(module, zone_config)
return manager.process_zone()
def main():
module = AnsibleModule(
argument_spec=dict(
zones=dict(
type='list',
elements='dict',
required=True,
options=dict(
name=dict(type='str', required=True),
dns_server=dict(type='str', required=True),
records=dict(
type='list',
elements='dict',
required=True,
options=dict(
record=dict(type='str', required=True),
type=dict(type='str', required=True),
value=dict(type='raw', required=True),
ttl=dict(type='int'),
state=dict(type='str', choices=['present', 'absent'], default='present')
)
)
)
),
key_name=dict(type='str'),
key_secret=dict(type='str', no_log=True),
key_algorithm=dict(
type='str',
default='hmac-md5',
choices=[
'HMAC-MD5.SIG-ALG.REG.INT',
'hmac-md5',
'hmac-sha1',
'hmac-sha224',
'hmac-sha256',
'hmac-sha384',
'hmac-sha512'
]
),
protocol=dict(type='str', default='tcp', choices=['tcp', 'udp']),
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)
),
supports_check_mode=True,
required_together=[
['key_name', 'key_secret']
]
)
# Validate dnspython dependency
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
module.exit_json(
changed=overall_changed,
failed=overall_failed,
results=results
)
if __name__ == '__main__':
main()