1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-02-03 23:41:51 +00:00

nsupdate: add server FQDN and GSS-TSIG support (#11425)

* nsupdate: support server FQDN

Right now, the server has to be specified as an IPv4/IPv6 address. This
adds support for specifing the server as a FQDN as well.

* nsupdate: support GSS-TSIG/Kerberos

Add support for GSS-TSIG (Kerberos) keys to nsupdate. This makes life
easier when working with Windows DNS servers or Bind in a Kerberos
environment.

Inspiration taken from here:
https://github.com/rthalley/dnspython/pull/530#issuecomment-1363265732

Closes: #5730

* nsupdate: introduce query helper function

This simplifies the code by moving the protocol checks, etc, into a
single place.

* nsupdate: try all server IP addresses

Change resolve_server() to generate a list of IPv[46] addresses, then
try all of them in a round-robin fashion in query().

* nsupdate: some more cleanups

As suggested in the PR review.

* nsupdate: apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
David Härdeman 2026-01-22 06:42:23 +01:00 committed by GitHub
parent 864695f898
commit 9fcd9338b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 170 additions and 55 deletions

View file

@ -70,6 +70,8 @@ ignore_missing_imports = True
ignore_missing_imports = True ignore_missing_imports = True
[mypy-github3.*] [mypy-github3.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-gssapi.*]
ignore_missing_imports = True
[mypy-hashids.*] [mypy-hashids.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-heroku3.*] [mypy-heroku3.*]

View file

@ -0,0 +1,2 @@
minor_changes:
- nsupdate - add support for server FQDN and the GSS-TSIG key algorithm (https://github.com/ansible-collections/community.general/issues/5730, https://github.com/ansible-collections/community.general/pull/11425).

View file

@ -19,6 +19,7 @@ description:
- Create, update and remove DNS records using DDNS updates. - Create, update and remove DNS records using DDNS updates.
requirements: requirements:
- dnspython - dnspython
- gssapi (when using GSS-TSIG authentication)
author: "Loic Blot (@nerzhul)" author: "Loic Blot (@nerzhul)"
extends_documentation_fragment: extends_documentation_fragment:
- community.general.attributes - community.general.attributes
@ -36,7 +37,8 @@ options:
type: str type: str
server: server:
description: description:
- Apply DNS modification on this server, specified by IPv4 or IPv6 address. - Apply DNS modification on this server, specified by IPv4/IPv6 address or FQDN.
- FQDNs are supported since community.general 12.3.0.
required: true required: true
type: str type: str
port: port:
@ -47,15 +49,19 @@ options:
key_name: key_name:
description: description:
- Use TSIG key name to authenticate against DNS O(server). - Use TSIG key name to authenticate against DNS O(server).
- Not required when using O(key_algorithm=gss-tsig).
type: str type: str
key_secret: key_secret:
description: description:
- Use TSIG key secret, associated with O(key_name), to authenticate against O(server). - Use TSIG key secret, associated with O(key_name), to authenticate against O(server).
- Not required when using O(key_algorithm=gss-tsig).
type: str type: str
key_algorithm: key_algorithm:
description: description:
- Specify key algorithm used by O(key_secret). - Specify 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'] - Use V(gss-tsig) for GSS-TSIG authentication (requires the gssapi library and Kerberos credentials).
- V(gss-tsig) was added in community.general 12.3.0.
choices: ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512', 'gss-tsig']
default: 'hmac-md5' default: 'hmac-md5'
type: str type: str
zone: zone:
@ -138,6 +144,23 @@ EXAMPLES = r"""
record: "1.1.168.192.in-addr.arpa." record: "1.1.168.192.in-addr.arpa."
type: "PTR" type: "PTR"
state: absent state: absent
- name: Use FQDN for server instead of IP address
community.general.nsupdate:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
server: "ns1.example.org"
zone: "example.org"
record: "ansible"
value: "192.168.1.1"
- name: Use GSS-TSIG authentication (requires Kerberos credentials)
community.general.nsupdate:
key_algorithm: "gss-tsig"
server: "ns1.example.org"
zone: "example.org"
record: "ansible"
value: "192.168.1.1"
""" """
RETURN = r""" RETURN = r"""
@ -178,43 +201,53 @@ dns_rc_str:
sample: 'REFUSED' sample: 'REFUSED'
""" """
import traceback import ipaddress
import time
import uuid
from binascii import Error as binascii_error from binascii import Error as binascii_error
from contextlib import suppress
DNSPYTHON_IMP_ERR = None from ansible.module_utils.basic import AnsibleModule
try:
from ansible_collections.community.general.plugins.module_utils import deps
with deps.declare("dnspython", url="https://github.com/rthalley/dnspython"):
import dns.message import dns.message
import dns.query import dns.query
import dns.rdtypes.ANY.TKEY
import dns.resolver import dns.resolver
import dns.tsigkeyring import dns.tsigkeyring
import dns.update import dns.update
HAVE_DNSPYTHON = True with deps.declare("gssapi", reason="for gss-tsig keys", url="https://github.com/pythongssapi/python-gssapi"):
except ImportError: import gssapi
DNSPYTHON_IMP_ERR = traceback.format_exc()
HAVE_DNSPYTHON = False
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
class RecordManager: class RecordManager:
def __init__(self, module): def __init__(self, module):
self.module = module self.module = module
self.server_fqdn = None
self.server_ips = self.resolve_server()
if module.params["key_algorithm"] == "hmac-md5":
self.algorithm = "HMAC-MD5.SIG-ALG.REG.INT"
elif module.params["key_algorithm"] == "gss-tsig":
if module.params["key_name"]:
self.module.fail_json(msg="key_name cannot be used with GSS-TSIG")
self.algorithm = dns.tsig.GSS_TSIG
self.keyring, self.keyname = self.init_gssapi()
else:
self.algorithm = module.params["key_algorithm"]
if module.params["key_name"]: if module.params["key_name"]:
try: try:
self.keyring = dns.tsigkeyring.from_text({module.params["key_name"]: module.params["key_secret"]}) self.keyring = dns.tsigkeyring.from_text({module.params["key_name"]: module.params["key_secret"]})
self.keyname = module.params["key_name"]
except TypeError: except TypeError:
module.fail_json(msg="Missing key_secret") module.fail_json(msg="Missing key_secret")
except binascii_error as e: except binascii_error as e:
module.fail_json(msg=f"TSIG key error: {e}") module.fail_json(msg=f"TSIG key error: {e}")
else:
self.keyring = None
if module.params["key_algorithm"] == "hmac-md5":
self.algorithm = "HMAC-MD5.SIG-ALG.REG.INT"
else:
self.algorithm = module.params["key_algorithm"]
if module.params["zone"] is None: if module.params["zone"] is None:
if module.params["record"][-1] != ".": if module.params["record"][-1] != ".":
@ -238,6 +271,108 @@ class RecordManager:
self.dns_rc = 0 self.dns_rc = 0
def resolve_server(self):
"""Resolve server parameter to a list of IP addresses if it's a FQDN."""
server = self.module.params["server"]
# Check if it's already an IPv4/IPv6 address
try:
ipaddress.ip_address(server)
return [server]
except ValueError:
pass
# Try to resolve the FQDN
try:
resolver = dns.resolver.Resolver()
name = dns.name.from_text(server)
ip_list = []
with suppress(dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
answers = resolver.resolve(name, dns.rdatatype.AAAA)
self.server_fqdn = server
ip_list.extend([str(answer) for answer in answers])
with suppress(dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
answers = resolver.resolve(name, dns.rdatatype.A)
self.server_fqdn = server
ip_list.extend([str(answer) for answer in answers])
if not ip_list:
self.module.fail_json(msg=f"Failed to resolve server '{server}' to an IP address")
return ip_list
except dns.exception.DNSException as e:
self.module.fail_json(msg=f"DNS resolution error for server '{server}': {e}")
def query(self, query, timeout=10):
last_exception = None
for server_ip in self.server_ips:
try:
if self.module.params["protocol"] == "tcp":
return dns.query.tcp(query, server_ip, timeout=timeout, port=self.module.params["port"])
else:
return dns.query.udp(query, server_ip, timeout=timeout, port=self.module.params["port"])
except (OSError, dns.exception.Timeout) as e:
last_exception = e
continue
# If all servers failed, raise the last exception
if last_exception:
raise last_exception
def build_tkey_query(self, token, key_ring, key_name):
inception_time = int(time.time())
tkey = dns.rdtypes.ANY.TKEY.TKEY(
dns.rdataclass.ANY,
dns.rdatatype.TKEY,
dns.tsig.GSS_TSIG,
inception_time,
inception_time,
3,
dns.rcode.NOERROR,
token,
b"",
)
query = dns.message.make_query(key_name, dns.rdatatype.TKEY, dns.rdataclass.ANY)
query.keyring = key_ring
query.find_rrset(dns.message.ADDITIONAL, key_name, dns.rdataclass.ANY, dns.rdatatype.TKEY, create=True).add(
tkey
)
return query
def init_gssapi(self):
deps.validate(self.module, "gssapi")
if not self.server_fqdn:
self.module.fail_json(msg="server must be a FQDN")
# Acquire GSSAPI credentials
gss_name = gssapi.Name(f"DNS@{self.server_fqdn}", gssapi.NameType.hostbased_service)
try:
gss_ctx = gssapi.SecurityContext(name=gss_name, usage="initiate")
except gssapi.exceptions.GSSError as e:
self.module.fail_json(msg=f"GSSAPI context initialization error: {e}")
# Generate unique key name
keyname = dns.name.from_text(f"{uuid.uuid4()}.{self.server_fqdn}")
tsig_key = dns.tsig.Key(keyname, gss_ctx, dns.tsig.GSS_TSIG)
keyring = dns.tsig.GSSTSigAdapter({keyname: tsig_key})
# Perform GSS-TSIG negotiation
token = gss_ctx.step()
while not gss_ctx.complete:
tkey_query = self.build_tkey_query(token, keyring, keyname)
try:
response = self.query(tkey_query)
except (OSError, dns.exception.Timeout) as e:
self.module.fail_json(msg=f"GSS-TSIG negotiation error: ({e.__class__.__name__}): {e}")
if not gss_ctx.complete:
token = gss_ctx.step(response.answer[0][0].key)
return (keyring, keyname)
def txt_helper(self, entry): def txt_helper(self, entry):
if entry[0] == '"' and entry[-1] == '"': if entry[0] == '"' and entry[-1] == '"':
return entry return entry
@ -248,16 +383,9 @@ class RecordManager:
while True: while True:
query = dns.message.make_query(name, dns.rdatatype.SOA) query = dns.message.make_query(name, dns.rdatatype.SOA)
if self.keyring: if self.keyring:
query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) query.use_tsig(keyring=self.keyring, keyname=self.keyname, algorithm=self.algorithm)
try: try:
if self.module.params["protocol"] == "tcp": lookup = self.query(query)
lookup = dns.query.tcp(
query, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
else:
lookup = dns.query.udp(
query, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e:
self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}") self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}")
except (OSError, dns.exception.Timeout) as e: except (OSError, dns.exception.Timeout) as e:
@ -285,14 +413,7 @@ class RecordManager:
def __do_update(self, update): def __do_update(self, update):
response = None response = None
try: try:
if self.module.params["protocol"] == "tcp": response = self.query(update)
response = dns.query.tcp(
update, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
else:
response = dns.query.udp(
update, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e:
self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}") self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}")
except (OSError, dns.exception.Timeout) as e: except (OSError, dns.exception.Timeout) as e:
@ -328,7 +449,7 @@ class RecordManager:
return result return result
def create_record(self): def create_record(self):
update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) update = dns.update.Update(self.zone, keyring=self.keyring, keyname=self.keyname, keyalgorithm=self.algorithm)
for entry in self.value: for entry in self.value:
try: try:
update.add(self.module.params["record"], self.module.params["ttl"], self.module.params["type"], entry) update.add(self.module.params["record"], self.module.params["ttl"], self.module.params["type"], entry)
@ -341,7 +462,7 @@ class RecordManager:
return dns.message.Message.rcode(response) return dns.message.Message.rcode(response)
def modify_record(self): def modify_record(self):
update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) update = dns.update.Update(self.zone, keyring=self.keyring, keyname=self.keyname, keyalgorithm=self.algorithm)
if self.module.params["type"].upper() == "NS": if self.module.params["type"].upper() == "NS":
# When modifying a NS record, Bind9 silently refuses to delete all the NS entries for a zone: # When modifying a NS record, Bind9 silently refuses to delete all the NS entries for a zone:
@ -351,17 +472,10 @@ class RecordManager:
# Let's perform dns inserts and updates first, deletes after. # Let's perform dns inserts and updates first, deletes after.
query = dns.message.make_query(self.module.params["record"], self.module.params["type"]) query = dns.message.make_query(self.module.params["record"], self.module.params["type"])
if self.keyring: if self.keyring:
query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) query.use_tsig(keyring=self.keyring, keyname=self.keyname, algorithm=self.algorithm)
try: try:
if self.module.params["protocol"] == "tcp": lookup = self.query(query)
lookup = dns.query.tcp(
query, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
else:
lookup = dns.query.udp(
query, self.module.params["server"], timeout=10, port=self.module.params["port"]
)
except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e:
self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}") self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}")
except (OSError, dns.exception.Timeout) as e: except (OSError, dns.exception.Timeout) as e:
@ -398,7 +512,7 @@ class RecordManager:
if self.module.check_mode: if self.module.check_mode:
self.module.exit_json(changed=True) self.module.exit_json(changed=True)
update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) update = dns.update.Update(self.zone, keyring=self.keyring, keyname=self.keyname, keyalgorithm=self.algorithm)
update.delete(self.module.params["record"], self.module.params["type"]) update.delete(self.module.params["record"], self.module.params["type"])
response = self.__do_update(update) response = self.__do_update(update)
@ -413,7 +527,7 @@ class RecordManager:
return result return result
def record_exists(self): def record_exists(self):
update = dns.update.Update(self.zone, keyring=self.keyring, keyalgorithm=self.algorithm) update = dns.update.Update(self.zone, keyring=self.keyring, keyname=self.keyname, keyalgorithm=self.algorithm)
try: try:
update.present(self.module.params["record"], self.module.params["type"]) update.present(self.module.params["record"], self.module.params["type"])
except dns.rdatatype.UnknownRdatatype as e: except dns.rdatatype.UnknownRdatatype as e:
@ -446,13 +560,10 @@ class RecordManager:
def ttl_changed(self): def ttl_changed(self):
query = dns.message.make_query(self.fqdn, self.module.params["type"]) query = dns.message.make_query(self.fqdn, self.module.params["type"])
if self.keyring: if self.keyring:
query.use_tsig(keyring=self.keyring, algorithm=self.algorithm) query.use_tsig(keyring=self.keyring, keyname=self.keyname, algorithm=self.algorithm)
try: try:
if self.module.params["protocol"] == "tcp": lookup = self.query(query)
lookup = dns.query.tcp(query, self.module.params["server"], timeout=10, port=self.module.params["port"])
else:
lookup = dns.query.udp(query, self.module.params["server"], timeout=10, port=self.module.params["port"])
except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e: except (dns.tsig.PeerBadKey, dns.tsig.PeerBadSignature) as e:
self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}") self.module.fail_json(msg=f"TSIG update error ({e.__class__.__name__}): {e}")
except (OSError, dns.exception.Timeout) as e: except (OSError, dns.exception.Timeout) as e:
@ -475,6 +586,7 @@ def main():
"hmac-sha256", "hmac-sha256",
"hmac-sha384", "hmac-sha384",
"hmac-sha512", "hmac-sha512",
"gss-tsig",
] ]
module = AnsibleModule( module = AnsibleModule(
@ -495,8 +607,7 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
if not HAVE_DNSPYTHON: deps.validate(module, "dnspython")
module.fail_json(msg=missing_required_lib("dnspython"), exception=DNSPYTHON_IMP_ERR)
if len(module.params["record"]) == 0: if len(module.params["record"]) == 0:
module.fail_json(msg="record cannot be empty.") module.fail_json(msg="record cannot be empty.")