Refactored verbose output to use standard Ansible logging patterns: - Removed: from ansible.utils.display import Display - Changed: self.display.vvv() → self.module.debug() - Maintains verbosity levels with self.module._verbosity checks - Reduces external dependencies - Improves compatibility with standard Ansible execution The module now logs zone changes (Added, Removed, Changed, Skipped) using self.module.debug() which works with -v and -vv flags.
959 lines
34 KiB
Python
959 lines
34 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright: Contributors to the Ansible project
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
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: "1.0.0"
|
|
author: Dan Kercher (@dkercher)
|
|
requirements:
|
|
- dnspython
|
|
extends_documentation_fragment:
|
|
- community.general.attributes
|
|
attributes:
|
|
check_mode:
|
|
support: full
|
|
diff_mode:
|
|
support: full
|
|
options:
|
|
zones:
|
|
description:
|
|
- List of zones to manage.
|
|
- 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
|
|
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.
|
|
- Optional if global O(dns_server) is provided.
|
|
type: str
|
|
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: []
|
|
ignore_dnssec_records:
|
|
description:
|
|
- 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
|
|
notes:
|
|
- Verbose output showing per-record actions is available with C(-v) flag.
|
|
- Use C(-v) to see which records were Added, Removed, Changed, or Skipped.
|
|
- Use C(--diff) flag to see before/after state of DNS records.
|
|
"""
|
|
|
|
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 and verbose output
|
|
community.general.nsupdate_zone:
|
|
key_name: "nsupdate"
|
|
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
|
|
# SOA and DNSSEC records are ignored by default
|
|
ignore_record_patterns:
|
|
- '^_acme-challenge\..*'
|
|
- '^_dnsauth\..*'
|
|
# Use -v flag for per-record action details
|
|
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 global server
|
|
community.general.nsupdate_zone:
|
|
key_name: "nsupdate"
|
|
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
|
|
dns_server: ns1.dns.com
|
|
protocol: tcp
|
|
zones:
|
|
- name: bigzone1.com
|
|
records: "{{ bigzone1_records }}"
|
|
|
|
- name: bigzone2.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 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: AnsibleModule, zone_config: dict) -> None:
|
|
self.module = module
|
|
self.zone_name_str = zone_config['name']
|
|
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 += '.'
|
|
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.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
|
|
self.current_zone = None
|
|
self.soa_minimum_ttl = 3600 # Default, will be updated from SOA
|
|
|
|
def _resolve_server(self) -> list[str]:
|
|
"""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) -> None:
|
|
"""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) -> None:
|
|
"""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) -> dict:
|
|
"""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 _validate_record_values(self, parsed_records: dict) -> None:
|
|
"""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) -> dict:
|
|
"""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: dns.name.Name, record_type: str) -> bool:
|
|
"""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: dict) -> dict:
|
|
"""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: dict, current_records: dict) -> dict:
|
|
"""Compute adds, deletes, and updates needed."""
|
|
changes = {
|
|
'adds': [],
|
|
'deletes': [],
|
|
'updates': []
|
|
}
|
|
|
|
# Find adds, updates, and unchanged records
|
|
for key, desired in desired_records.items():
|
|
name_str = desired['name'].to_text()
|
|
record_type = desired['type']
|
|
|
|
if desired['state'] == 'absent':
|
|
# Handle explicit absents
|
|
if key in current_records:
|
|
changes['deletes'].append({
|
|
'name': desired['name'],
|
|
'type': desired['type'],
|
|
'values': desired['values']
|
|
})
|
|
if self.module._verbosity >= 1 or self.module._diff:
|
|
self.module.debug(f"[{self.zone_name_str}] Removed: {name_str} {desired['type']}")
|
|
else:
|
|
# State is 'present'
|
|
if key not in current_records:
|
|
# Record doesn't exist - add it
|
|
changes['adds'].append(desired)
|
|
if self.module._verbosity >= 1 or self.module._diff:
|
|
values_str = ', '.join(str(v) for v in desired['values'])
|
|
self.module.debug(f"[{self.zone_name_str}] Added: {name_str} {record_type} {values_str}")
|
|
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)
|
|
if self.module._verbosity >= 1 or self.module._diff:
|
|
before_values = ', '.join(str(v) for v in current['values'])
|
|
after_values = ', '.join(str(v) for v in desired['values'])
|
|
self.module.debug(f"[{self.zone_name_str}] Changed: {name_str} {record_type} ({before_values} -> {after_values})")
|
|
else:
|
|
# Record unchanged
|
|
if self.module._verbosity >= 2:
|
|
self.module.debug(f"[{self.zone_name_str}] Skipped: {name_str} {record_type} (unchanged)")
|
|
|
|
# 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']
|
|
|
|
changes['deletes'].append({
|
|
'name': current['name'],
|
|
'type': current['type'],
|
|
'values': current['values']
|
|
})
|
|
if self.module._verbosity >= 1 or self.module._diff:
|
|
self.module.debug(f"[{self.zone_name_str}] Removed: {name_str} {record_type}")
|
|
|
|
return changes
|
|
|
|
def _validate_cname_conflicts(self, desired_records: dict) -> None:
|
|
"""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: dict) -> dict:
|
|
"""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) -> dict:
|
|
"""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 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
|
|
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'])
|
|
|
|
# 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)
|
|
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 _format_diff(self, changes: dict) -> dict:
|
|
"""Format changes as a diff structure for diff mode."""
|
|
diff_before = {}
|
|
diff_after = {}
|
|
|
|
# Process adds
|
|
for record in changes['adds']:
|
|
record_key = f"{record['name'].to_text()} {record['type']}"
|
|
diff_after[record_key] = sorted(str(v) for v in record['values'])
|
|
|
|
# Process deletes
|
|
for record in changes['deletes']:
|
|
record_key = f"{record['name'].to_text()} {record['type']}"
|
|
diff_before[record_key] = sorted(str(v) for v in record['values'])
|
|
|
|
# Process updates
|
|
for record in changes['updates']:
|
|
record_key = f"{record['name'].to_text()} {record['type']}"
|
|
# For updates, we need to show before state from current_records
|
|
# Since we don't have it here, we'll just show after state
|
|
diff_after[record_key] = sorted(str(v) for v in record['values'])
|
|
|
|
return {
|
|
'before': diff_before,
|
|
'after': diff_after
|
|
}
|
|
|
|
|
|
def process_single_zone(module: AnsibleModule, zone_config: dict) -> dict:
|
|
"""Process a single zone (for parallel execution)."""
|
|
manager = DNSZoneManager(module, zone_config)
|
|
return manager.process_zone()
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point for the module."""
|
|
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'),
|
|
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=[]),
|
|
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')
|
|
),
|
|
supports_check_mode=True,
|
|
supports_diff_mode=True,
|
|
required_together=[
|
|
['key_name', 'key_secret']
|
|
]
|
|
)
|
|
|
|
# Validate dnspython dependency
|
|
deps.validate(module, "dnspython")
|
|
|
|
zones = module.params['zones']
|
|
|
|
results = []
|
|
overall_changed = False
|
|
overall_failed = False
|
|
|
|
# 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,
|
|
failed=overall_failed,
|
|
results=results
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|