Repository navigation
Expand file tree
/
Copy pathdns_rfc2136.py
More file actions
277 lines (255 loc) · 12.1 KB
/
Copy pathdns_rfc2136.py
File metadata and controls
277 lines (255 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#!/usr/bin/env python3
# Copyright (c) 2025-2026 Tim Riker
# SPDX-License-Identifier: MIT
"""RFC2136 DNS updater helpers with CNAME-following for ACME DNS-01.
This module uses dnspython to resolve CNAMEs and perform TSIG-signed DNS updates
to add and remove _acme-challenge TXT records where required.
The public API is:
- resolve_cname_target(fqdn) -> returns the ultimate target FQDN if a CNAME chain
exists for the supplied name, otherwise returns the original fqdn.
- update_txt_record(server, port, key_name, key, algorithm, zone, name, txt_value)
-> performs an add/replace of the TXT record for `name` in `zone` on the given
server using TSIG credentials.
The code intentionally keeps operations explicit and synchronous so the CLI
workflow can call it when ready to publish and remove challenge TXT records.
"""
from typing import Optional
import socket
import dns.resolver
import dns.update
import dns.query
import dns.tsigkeyring
import dns.exception
import logging
log = logging.getLogger(__name__)
def resolve_cname_target(fqdn: str, nameserver: Optional[str] = None,
key_name: Optional[str] = None, key: Optional[str] = None,
algorithm: Optional[str] = None) -> str:
"""Follow CNAME chain for fqdn and return the final target.
If no CNAME exists, returns the original fqdn.
If TSIG credentials provided, uses them for queries.
"""
try:
if nameserver and key_name and key:
# Use TSIG-authenticated query
keyring = None
algo_const = None
if algorithm:
algo_map = {
'hmac-md5': dns.tsig.HMAC_MD5,
'hmac-sha1': dns.tsig.HMAC_SHA1,
'hmac-sha224': dns.tsig.HMAC_SHA224,
'hmac-sha256': dns.tsig.HMAC_SHA256,
'hmac-sha384': dns.tsig.HMAC_SHA384,
'hmac-sha512': dns.tsig.HMAC_SHA512,
}
algo_const = algo_map.get(algorithm.lower())
try:
keyring = dns.tsigkeyring.from_text({key_name: key})
except Exception:
# Try without trailing dot
key_name_stripped = key_name.rstrip('.')
keyring = dns.tsigkeyring.from_text({key_name_stripped: key})
key_name = key_name_stripped
q = dns.message.make_query(fqdn, 'CNAME', use_edns=True)
q.use_tsig(keyring=keyring, keyname=key_name, algorithm=algo_const)
try:
response = dns.query.tcp(q, nameserver, timeout=10)
for rrset in response.answer:
if rrset.rdtype == dns.rdatatype.CNAME:
for rdata in rrset:
target = rdata.target.to_text(omit_final_dot=True)
return resolve_cname_target(target, nameserver, key_name, key, algorithm)
return fqdn
except Exception as e:
log.debug("TSIG CNAME query for %s failed: %s", fqdn, e)
return fqdn
else:
# Use default resolver
answers = dns.resolver.resolve(fqdn, "CNAME")
for r in answers:
target = r.target.to_text(omit_final_dot=True)
return resolve_cname_target(target, nameserver, key_name, key, algorithm)
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers):
return fqdn
except Exception as e:
log.debug("CNAME resolution for %s failed: %s", fqdn, e)
return fqdn
def discover_zone_for_name(fqdn: str, nameserver: Optional[str] = None,
key_name: Optional[str] = None, key: Optional[str] = None,
algorithm: Optional[str] = None) -> str:
"""Discover the authoritative zone for a given FQDN by walking up
the domain labels and looking for an SOA record (preferred) or NS record.
Returns the zone name (without trailing dot). If discovery fails, returns
the last two labels as a best-effort fallback.
If TSIG credentials provided, uses them for queries.
"""
name = fqdn.rstrip('.')
labels = name.split('.')
# Setup TSIG if provided
keyring = None
algo_const = None
if nameserver and key_name and key:
if algorithm:
algo_map = {
'hmac-md5': dns.tsig.HMAC_MD5,
'hmac-sha1': dns.tsig.HMAC_SHA1,
'hmac-sha224': dns.tsig.HMAC_SHA224,
'hmac-sha256': dns.tsig.HMAC_SHA256,
'hmac-sha384': dns.tsig.HMAC_SHA384,
'hmac-sha512': dns.tsig.HMAC_SHA512,
}
algo_const = algo_map.get(algorithm.lower())
try:
keyring = dns.tsigkeyring.from_text({key_name: key})
except Exception:
key_name = key_name.rstrip('.')
keyring = dns.tsigkeyring.from_text({key_name: key})
# Walk from the full name up to the root, looking for SOA/NS records
for i in range(len(labels)):
candidate = '.'.join(labels[i:])
try:
if keyring:
# Use TSIG query
q = dns.message.make_query(candidate, 'SOA', use_edns=True)
q.use_tsig(keyring=keyring, keyname=key_name, algorithm=algo_const)
try:
response = dns.query.tcp(q, nameserver, timeout=10)
if response.answer:
log.debug("Discovered SOA for %s; using zone %s", fqdn, candidate)
return candidate
except Exception:
pass
else:
# Use default resolver
dns.resolver.resolve(candidate, 'SOA')
log.debug("Discovered SOA for %s; using zone %s", fqdn, candidate)
return candidate
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers):
# try NS as a fallback
try:
if keyring:
q = dns.message.make_query(candidate, 'NS', use_edns=True)
q.use_tsig(keyring=keyring, keyname=key_name, algorithm=algo_const)
response = dns.query.tcp(q, nameserver, timeout=10)
if response.answer:
log.debug("Found NS for %s; using zone %s", fqdn, candidate)
return candidate
else:
answers = dns.resolver.resolve(candidate, 'NS')
if answers.rrset:
log.debug("Found NS for %s; using zone %s", fqdn, candidate)
return candidate
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers):
continue
except Exception as e:
log.debug("Error while discovering zone for %s at %s: %s", fqdn, candidate, e)
continue
# Fallback: use last two labels
parts = name.split('.')
if len(parts) >= 2:
fallback = '.'.join(parts[-2:])
else:
fallback = name
log.debug("Zone discovery failed for %s; falling back to %s", fqdn, fallback)
return fallback
def update_txt_record(server: str,
port: int,
key_name: Optional[str],
key: Optional[str],
algorithm: Optional[str],
zone: str,
name: str,
txt_value: str,
ttl: int = 60,
delete_txt_value: Optional[str] = None) -> None:
"""Add a TXT record, or delete a specific TXT value, in ``zone``.
- server: target authoritative DNS server ip/host
- key_name/key: TSIG key name and base64 key string (optional)
- algorithm: e.g. 'hmac-sha256' (optional)
- zone: DNS zone to update (e.g. example.com)
- name: full name to set (e.g. _acme-challenge.example.com.) or relative name
- txt_value: the TXT value to add (without surrounding quotes); empty for deletion
- delete_txt_value: when set, delete only this TXT value and preserve other values
"""
# Prepare candidate key names (try with and without trailing dot).
candidates = [None]
if key_name and key:
if key_name.endswith('.'):
candidates = [key_name, key_name.rstrip('.')]
else:
candidates = [key_name, key_name + '.']
try:
# Resolve server hostname to IP addresses (supports both hostnames and IPs).
addrs = []
try:
for res in socket.getaddrinfo(server, None):
family, _socktype, _proto, _canonname, sockaddr = res
ip = sockaddr[0]
addrs.append((family, ip))
except Exception:
# fallback: use server string as-is (may be IP already)
addrs = [(socket.AF_UNSPEC, server)]
last_exc = None
# Try each candidate key name (if any) and each resolved IP address.
for candidate in candidates:
# build keyring/update object per candidate
keyring = None
if candidate and key:
try:
keyring = dns.tsigkeyring.from_text({candidate: key})
except Exception as e:
log.debug("Failed to build keyring for %s: %s", candidate, e)
last_exc = e
continue
# Map algorithm string to dnspython constant if provided.
algo_const = None
if algorithm:
algo_lower = algorithm.lower()
algo_map = {
'hmac-md5': dns.tsig.HMAC_MD5,
'hmac-sha1': dns.tsig.HMAC_SHA1,
'hmac-sha224': dns.tsig.HMAC_SHA224,
'hmac-sha256': dns.tsig.HMAC_SHA256,
'hmac-sha384': dns.tsig.HMAC_SHA384,
'hmac-sha512': dns.tsig.HMAC_SHA512,
}
algo_const = algo_map.get(algo_lower)
if not algo_const:
log.debug("Unrecognized TSIG algorithm '%s'; falling back to default", algorithm)
if keyring and candidate:
u = dns.update.Update(zone, keyring=keyring, keyname=candidate, keyalgorithm=algo_const)
else:
u = dns.update.Update(zone)
for family, ip in addrs:
try:
# name within zone: make it relative if it's a subdomain
relative_name = name
if name.endswith('.' + zone):
relative_name = name[:-(len(zone) + 1)]
if delete_txt_value is not None:
# ACME authorizations can share a name. Delete just this
# challenge token so other active validations survive.
u.delete(relative_name, 'TXT', f'"{delete_txt_value}"')
log.debug("Sending DNS delete to %s:%s for zone %s: %s value (key=%s)",
ip, port, zone, relative_name, candidate)
elif txt_value:
u.add(relative_name, ttl, 'TXT', txt_value)
log.debug("Sending DNS update to %s:%s for zone %s: %s -> %s (key=%s)",
ip, port, zone, relative_name, txt_value, candidate)
else:
u.delete(relative_name, 'TXT')
log.debug("Sending DNS delete to %s:%s for zone %s: %s (key=%s)",
ip, port, zone, relative_name, candidate)
response = dns.query.tcp(u, ip, port=port, timeout=10)
log.debug("DNS update response from %s: %s", ip, response)
return
except Exception as e:
log.debug("DNS update to %s using key %s failed: %s", ip, candidate, e)
last_exc = e
# If we get here, all attempts failed
if last_exc:
raise last_exc
except dns.exception.DNSException as e:
log.error("DNS update failed for %s on %s: %s", name, server, e)
raise