Skip to content

Commit 5946564

Browse files
committed
feat(api): add knot nslord backend
- split change tracking into base + PDNS/Knot implementations with NSLord routing - add domain nslord field, serializer support, and migration - implement knot backend (DNSKEY/AXFR/updates) and nslord_knot service/config - update e2e2 and unit tests for knot domains, cert handling, and fail-fast - tune PDNS API timeout and nsmaster transfer intervals
1 parent 07ad3c6 commit 5946564

33 files changed

Lines changed: 1907 additions & 164 deletions

api/api/celery.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pprint
33

44
import django.utils.log
5+
from django.apps import apps as django_apps
56
from celery import Celery
67
from celery.signals import task_failure
78

@@ -26,8 +27,23 @@ def debug_task(self):
2627
print("Request: {0!r}".format(self.request))
2728

2829

30+
logger = logging.getLogger(__name__)
31+
32+
33+
def _configure_logger():
34+
if getattr(_configure_logger, "configured", False):
35+
return
36+
if not django_apps.ready:
37+
return
38+
handler = django.utils.log.AdminEmailHandler()
39+
handler.setFormatter(CeleryFormatter())
40+
logger.addHandler(handler)
41+
_configure_logger.configured = True
42+
43+
2944
@task_failure.connect()
3045
def task_failure(task_id, exception, args, kwargs, traceback, einfo, **other_kwargs):
46+
_configure_logger()
3147
try:
3248
sender = other_kwargs.get("sender").name
3349
except AttributeError:
@@ -47,9 +63,6 @@ def task_failure(task_id, exception, args, kwargs, traceback, einfo, **other_kwa
4763
},
4864
exc_info=einfo,
4965
)
50-
51-
52-
django.setup()
5366
logger = logging.getLogger(__name__)
5467
handler = django.utils.log.AdminEmailHandler()
5568
handler.setFormatter(CeleryFormatter())

api/api/settings.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,28 @@
175175
NSLORD_PDNS_API_TOKEN = os.environ["DESECSTACK_NSLORD_APIKEY"]
176176
NSMASTER_PDNS_API = "http://nsmaster:8081/api/v1/servers/localhost"
177177
NSMASTER_PDNS_API_TOKEN = os.environ["DESECSTACK_NSMASTER_APIKEY"]
178+
NSLORD_KNOT_HOST = os.environ.get("DESECSTACK_NSLORD_KNOT_HOST", "nslord_knot")
179+
NSLORD_KNOT_PORT = int(os.environ.get("DESECSTACK_NSLORD_KNOT_PORT", "53"))
180+
NSLORD_KNOT_TIMEOUT = float(os.environ.get("DESECSTACK_NSLORD_KNOT_TIMEOUT", "5"))
181+
NSLORD_KNOT_UPDATE_KEY_NAME = os.environ.get(
182+
"DESECSTACK_NSLORD_KNOT_UPDATE_KEY_NAME", "nslord-update"
183+
)
184+
NSLORD_KNOT_UPDATE_KEY_SECRET = os.environ.get(
185+
"DESECSTACK_NSLORD_KNOT_UPDATE_KEY_SECRET", ""
186+
)
187+
NSLORD_KNOT_UPDATE_KEY_ALGORITHM = os.environ.get(
188+
"DESECSTACK_NSLORD_KNOT_UPDATE_KEY_ALGORITHM", "hmac-sha256"
189+
)
190+
NSLORD_KNOT_TRANSFER_KEY_NAME = os.environ.get(
191+
"DESECSTACK_NSLORD_KNOT_TRANSFER_KEY_NAME", "nsmaster-xfr"
192+
)
193+
NSLORD_KNOT_TRANSFER_KEY_SECRET = os.environ.get(
194+
"DESECSTACK_NSLORD_KNOT_TRANSFER_KEY_SECRET",
195+
os.environ.get("DESECSTACK_NSMASTER_TSIGKEY", ""),
196+
)
197+
NSLORD_KNOT_TRANSFER_KEY_ALGORITHM = os.environ.get(
198+
"DESECSTACK_NSLORD_KNOT_TRANSFER_KEY_ALGORITHM", "hmac-sha256"
199+
)
178200
CATALOG_ZONE = "catalog.internal"
179201

180202
# Celery
@@ -193,6 +215,7 @@
193215
# pdns accepts request payloads of this size.
194216
# This will hopefully soon be configurable: https://github.com/PowerDNS/pdns/pull/7550
195217
PDNS_MAX_BODY_SIZE = 16 * 1024 * 1024
218+
PDNS_API_TIMEOUT = float(os.environ.get("DESECSTACK_PDNS_API_TIMEOUT", "10"))
196219

197220
# SEPA direct debit settings
198221
SEPA = {

api/desecapi/exception_handlers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from rest_framework.views import exception_handler as drf_exception_handler
77

88
from desecapi import metrics
9-
from desecapi.exceptions import PDNSException
9+
from desecapi.exceptions import KnotException, PDNSException
1010

1111

1212
def exception_handler(exc, context):
@@ -39,6 +39,7 @@ def _500():
3939
IntegrityError: _409,
4040
OSError: _500, # OSError happens on system-related errors, like full disk or getaddrinfo() failure.
4141
PDNSException: _500, # nslord/nsmaster returned an error
42+
KnotException: _500, # knot returned an error
4243
}
4344

4445
for exception_class, handler in handlers.items():

api/desecapi/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class PCHException(ExternalAPIException):
3434
pass
3535

3636

37+
class KnotException(APIException):
38+
pass
39+
40+
3741
class ConcurrencyException(APIException):
3842
status_code = status.HTTP_429_TOO_MANY_REQUESTS
3943
default_detail = "Too many concurrent requests."

api/desecapi/knot.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
from functools import lru_cache
2+
from hashlib import sha1
3+
import socket
4+
import time
5+
6+
import dns.dnssec
7+
import dns.message
8+
import dns.name
9+
import dns.query
10+
import dns.rcode
11+
import dns.rdtypes.ANY.DNSKEY
12+
import dns.rdatatype
13+
import dns.tsig
14+
import dns.tsigkeyring
15+
import dns.update
16+
import dns.zone
17+
from django.conf import settings
18+
19+
from desecapi.exceptions import KnotException
20+
21+
22+
DEFAULT_SOA_CONTENT = "get.desec.io. get.desec.io. 1 86400 3600 2419200 3600"
23+
24+
25+
_TSIG_ALGORITHM_MAP = {
26+
"hmac-md5": dns.tsig.HMAC_MD5,
27+
"hmac-sha1": dns.tsig.HMAC_SHA1,
28+
"hmac-sha224": dns.tsig.HMAC_SHA224,
29+
"hmac-sha256": dns.tsig.HMAC_SHA256,
30+
"hmac-sha256-128": dns.tsig.HMAC_SHA256_128,
31+
"hmac-sha384": dns.tsig.HMAC_SHA384,
32+
"hmac-sha384-192": dns.tsig.HMAC_SHA384_192,
33+
"hmac-sha512": dns.tsig.HMAC_SHA512,
34+
"hmac-sha512-256": dns.tsig.HMAC_SHA512_256,
35+
}
36+
37+
38+
def _tsig_algorithm(name):
39+
if not name:
40+
return None
41+
algorithm = _TSIG_ALGORITHM_MAP.get(name.lower())
42+
if algorithm is None:
43+
raise KnotException(f"Unsupported TSIG algorithm: {name}")
44+
return algorithm
45+
46+
47+
@lru_cache(maxsize=1)
48+
def _knot_host_ip():
49+
host = settings.NSLORD_KNOT_HOST
50+
try:
51+
dns.inet.af_for_address(host)
52+
return host
53+
except ValueError:
54+
pass
55+
addrinfo = []
56+
for family in (socket.AF_INET, socket.AF_INET6):
57+
try:
58+
addrinfo = socket.getaddrinfo(
59+
host, None, family=family, type=socket.SOCK_STREAM
60+
)
61+
except socket.gaierror:
62+
continue
63+
if addrinfo:
64+
break
65+
if not addrinfo:
66+
raise KnotException(f"Failed to resolve NSLORD_KNOT_HOST {host!r}")
67+
return addrinfo[0][4][0]
68+
69+
70+
def _update_keyring():
71+
key_name = settings.NSLORD_KNOT_UPDATE_KEY_NAME
72+
key_secret = settings.NSLORD_KNOT_UPDATE_KEY_SECRET
73+
if not key_name or not key_secret:
74+
return None, None, None
75+
keyring = dns.tsigkeyring.from_text({key_name: key_secret})
76+
return (
77+
keyring,
78+
dns.name.from_text(key_name),
79+
_tsig_algorithm(settings.NSLORD_KNOT_UPDATE_KEY_ALGORITHM),
80+
)
81+
82+
83+
def _transfer_keyring():
84+
key_name = settings.NSLORD_KNOT_TRANSFER_KEY_NAME
85+
key_secret = settings.NSLORD_KNOT_TRANSFER_KEY_SECRET
86+
if not key_name or not key_secret:
87+
return None, None, None
88+
keyring = dns.tsigkeyring.from_text({key_name: key_secret})
89+
return (
90+
keyring,
91+
dns.name.from_text(key_name),
92+
_tsig_algorithm(settings.NSLORD_KNOT_TRANSFER_KEY_ALGORITHM),
93+
)
94+
95+
96+
def _send_update(update: dns.update.Update):
97+
response = dns.query.tcp(
98+
update,
99+
_knot_host_ip(),
100+
port=settings.NSLORD_KNOT_PORT,
101+
timeout=settings.NSLORD_KNOT_TIMEOUT,
102+
)
103+
if response.rcode() != dns.rcode.NOERROR:
104+
raise KnotException(
105+
f"Knot update failed with rcode {dns.rcode.to_text(response.rcode())}"
106+
)
107+
108+
109+
def _send_update_with_retry(
110+
update: dns.update.Update,
111+
*,
112+
attempts: int = 5,
113+
delay_seconds: float = 1.0,
114+
retry_rcodes: tuple[str, ...] = ("NOTAUTH", "SERVFAIL"),
115+
):
116+
last_exc = None
117+
for attempt in range(attempts):
118+
try:
119+
_send_update(update)
120+
return
121+
except KnotException as exc:
122+
last_exc = exc
123+
if attempt < attempts - 1 and any(code in str(exc) for code in retry_rcodes):
124+
time.sleep(delay_seconds)
125+
continue
126+
raise
127+
if last_exc is not None:
128+
raise last_exc
129+
130+
131+
def _catalog_member_subname(zone):
132+
zone = zone.rstrip(".") + "."
133+
return f"{sha1(zone.encode()).hexdigest()}.zones"
134+
135+
136+
def _catalog_record_name(zone):
137+
return f"{_catalog_member_subname(zone)}.{settings.CATALOG_ZONE}".strip(".") + "."
138+
139+
140+
def _new_update(zone):
141+
keyring, keyname, keyalgorithm = _update_keyring()
142+
return dns.update.Update(
143+
zone,
144+
keyring=keyring,
145+
keyname=keyname,
146+
keyalgorithm=keyalgorithm,
147+
)
148+
149+
150+
def create_zone(name):
151+
catalog_update = _new_update(settings.CATALOG_ZONE)
152+
catalog_update.replace(_catalog_record_name(name), 0, "PTR", name.rstrip(".") + ".")
153+
_send_update(catalog_update)
154+
155+
156+
def wait_for_zone(name, *, attempts=20, interval_seconds=0.5) -> bool:
157+
query = dns.message.make_query(name, dns.rdatatype.SOA)
158+
query_timeout = min(settings.NSLORD_KNOT_TIMEOUT, 1.0)
159+
160+
for _ in range(attempts):
161+
try:
162+
response = dns.query.tcp(
163+
query,
164+
_knot_host_ip(),
165+
port=settings.NSLORD_KNOT_PORT,
166+
timeout=query_timeout,
167+
)
168+
if any(rrset.rdtype == dns.rdatatype.SOA for rrset in response.answer):
169+
return True
170+
except Exception:
171+
pass
172+
if interval_seconds:
173+
time.sleep(interval_seconds)
174+
175+
return False
176+
177+
178+
def delete_zone(name):
179+
catalog_update = _new_update(settings.CATALOG_ZONE)
180+
catalog_update.delete(_catalog_record_name(name), "PTR")
181+
_send_update(catalog_update)
182+
183+
184+
def update_rrsets(domain_name, additions, modifications, deletions):
185+
from desecapi.models import RR, RRset
186+
187+
update = _new_update(domain_name)
188+
has_changes = False
189+
190+
for type_, subname in deletions:
191+
rrset_name = RRset.construct_name(subname, domain_name)
192+
update.delete(rrset_name, type_)
193+
has_changes = True
194+
195+
for type_, subname in (additions | modifications) - deletions:
196+
rrset_name = RRset.construct_name(subname, domain_name)
197+
ttl = RRset.objects.values_list("ttl", flat=True).get(
198+
domain__name=domain_name, type=type_, subname=subname
199+
)
200+
records = [
201+
rr.content
202+
for rr in RR.objects.filter(
203+
rrset__domain__name=domain_name,
204+
rrset__type=type_,
205+
rrset__subname=subname,
206+
)
207+
]
208+
if records:
209+
update.replace(rrset_name, ttl, type_, *records)
210+
else:
211+
update.delete(rrset_name, type_)
212+
has_changes = True
213+
214+
if has_changes:
215+
_send_update(update)
216+
217+
218+
def get_zonefile(domain) -> bytes:
219+
keyring, keyname, keyalgorithm = _transfer_keyring()
220+
xfr = dns.query.xfr(
221+
_knot_host_ip(),
222+
domain.name,
223+
port=settings.NSLORD_KNOT_PORT,
224+
timeout=settings.NSLORD_KNOT_TIMEOUT,
225+
keyring=keyring,
226+
keyname=keyname,
227+
keyalgorithm=keyalgorithm,
228+
)
229+
zone = dns.zone.from_xfr(xfr, relativize=False)
230+
if zone is None:
231+
raise KnotException("Knot AXFR returned no data")
232+
return zone.to_text(relativize=False).encode()
233+
234+
235+
def get_keys(domain):
236+
query = dns.message.make_query(domain.name, dns.rdatatype.DNSKEY, want_dnssec=True)
237+
response = dns.query.tcp(
238+
query,
239+
_knot_host_ip(),
240+
port=settings.NSLORD_KNOT_PORT,
241+
timeout=settings.NSLORD_KNOT_TIMEOUT,
242+
)
243+
if response.rcode() != dns.rcode.NOERROR:
244+
raise KnotException(
245+
f"Knot DNSKEY query failed with rcode {dns.rcode.to_text(response.rcode())}"
246+
)
247+
keys = []
248+
for rrset in response.answer:
249+
if rrset.rdtype != dns.rdatatype.DNSKEY:
250+
continue
251+
for rdata in rrset:
252+
key_text = rdata.to_text()
253+
name = dns.name.from_text(domain.name)
254+
key_is_sep = rdata.flags & dns.rdtypes.ANY.DNSKEY.SEP
255+
keys.append(
256+
{
257+
"dnskey": key_text,
258+
"ds": (
259+
[
260+
dns.dnssec.make_ds(name, rdata, algo).to_text()
261+
for algo in (2, 4)
262+
]
263+
if key_is_sep
264+
else []
265+
),
266+
"flags": rdata.flags,
267+
"keytype": None,
268+
}
269+
)
270+
return keys

api/desecapi/management/commands/chores.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import dns.message, dns.rdatatype, dns.query
99

1010
from desecapi import models
11-
from desecapi.pdns_change_tracker import PDNSChangeTracker
11+
from desecapi.pdns_change_tracker import NSLordChangeTracker
1212

1313

1414
class Command(BaseCommand):
@@ -40,7 +40,7 @@ def update_healthcheck_timestamp():
4040
return
4141

4242
content = f'"{int(time.time())}"'
43-
with PDNSChangeTracker():
43+
with NSLordChangeTracker():
4444
rrset, _ = domain.rrset_set.update_or_create(
4545
subname="", type="TXT", defaults={"ttl": settings.MINIMUM_TTL_DEFAULT}
4646
)

0 commit comments

Comments
 (0)