Skip to content

Commit e75380b

Browse files
committed
feat(api): allow moving between nslord backends
1 parent ab7326b commit e75380b

20 files changed

Lines changed: 723 additions & 87 deletions

api/api/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@
178178
NSLORD_KNOT_HOST = os.environ.get("DESECSTACK_NSLORD_KNOT_HOST", "nslord_knot")
179179
NSLORD_KNOT_PORT = int(os.environ.get("DESECSTACK_NSLORD_KNOT_PORT", "53"))
180180
NSLORD_KNOT_TIMEOUT = float(os.environ.get("DESECSTACK_NSLORD_KNOT_TIMEOUT", "5"))
181+
NSLORD_KNOT_KEY_READY_TIMEOUT = float(
182+
os.environ.get("DESECSTACK_NSLORD_KNOT_KEY_READY_TIMEOUT", "30")
183+
)
184+
NSLORD_KNOT_IMPORT_DIR = os.environ.get(
185+
"DESECSTACK_NSLORD_KNOT_IMPORT_DIR", "/knot-import"
186+
)
181187
NSLORD_KNOT_UPDATE_KEY_NAME = os.environ.get(
182188
"DESECSTACK_NSLORD_KNOT_UPDATE_KEY_NAME", "nslord-update"
183189
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
("desecapi", "0046_domain_nslord"),
7+
]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="domain",
12+
name="csk_private_key_encrypted",
13+
field=models.BinaryField(blank=True, null=True),
14+
),
15+
]

api/desecapi/models/domains.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from dns.resolver import NoNameservers
1616
from rest_framework.exceptions import APIException
1717

18-
from desecapi import logger, metrics, nslord
18+
from desecapi import crypto, logger, metrics, nslord
1919

2020
from .base import validate_domain_name
2121
from .records import RRset
@@ -66,6 +66,7 @@ class RenewalState(models.IntegerChoices):
6666
nslord = models.CharField(
6767
max_length=16, choices=NSLord.choices, default=NSLord.PDNS
6868
)
69+
csk_private_key_encrypted = models.BinaryField(null=True, blank=True)
6970
renewal_state = models.IntegerField(
7071
choices=RenewalState.choices, db_index=True, default=RenewalState.IMMORTAL
7172
)
@@ -302,6 +303,25 @@ def update_delegation(self, child_domain: Domain):
302303
def delete(self, *args, **kwargs):
303304
ret = super().delete(*args, **kwargs)
304305
logger.warning(f"Domain {self.name} deleted (owner: {self.owner.pk})")
306+
307+
def set_csk_private_key(self, private_key: str | None) -> None:
308+
if private_key is None:
309+
self.csk_private_key_encrypted = None
310+
else:
311+
if self.pk is None:
312+
raise ValueError("Domain must be saved before storing private key")
313+
self.csk_private_key_encrypted = crypto.encrypt(
314+
private_key.encode(), context=f"domain_csk:{self.pk}"
315+
)
316+
self.save(update_fields=["csk_private_key_encrypted"])
317+
318+
def get_csk_private_key(self) -> str | None:
319+
if not self.csk_private_key_encrypted:
320+
return None
321+
_, decrypted = crypto.decrypt(
322+
self.csk_private_key_encrypted, context=f"domain_csk:{self.pk}"
323+
)
324+
return decrypted.decode()
305325
return ret
306326

307327
def __str__(self):

api/desecapi/nslord.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
import logging
22

3+
import dns.message
4+
import dns.name
5+
import dns.query
6+
import dns.rdataclass
7+
import dns.rdatatype
8+
import dns.zone
9+
from django.conf import settings
10+
311
from desecapi import knot, pdns
412
from desecapi.exceptions import KnotException
513

614
logger = logging.getLogger(__name__)
715

16+
_DNSSEC_TYPES = {
17+
"DNSKEY",
18+
"CDS",
19+
"CDNSKEY",
20+
"RRSIG",
21+
"NSEC",
22+
"NSEC3",
23+
"NSEC3PARAM",
24+
}
25+
826

927
def get_keys(domain):
1028
if getattr(domain, "nslord", None) == "knot":
@@ -22,3 +40,79 @@ def get_zonefile(domain) -> bytes:
2240
if getattr(domain, "nslord", None) == "knot":
2341
return knot.get_zonefile(domain)
2442
return pdns.get_zonefile(domain)
43+
44+
45+
def get_zonefile_without_dnssec(domain) -> bytes:
46+
zonefile = get_zonefile(domain).decode()
47+
rrsets = zonefile_to_rrsets(domain.name, zonefile)
48+
return rrsets_to_zonefile(domain.name, rrsets).encode()
49+
50+
51+
def zonefile_to_rrsets(domain_name: str, zonefile: str):
52+
zone = dns.zone.from_text(
53+
zonefile,
54+
origin=dns.name.from_text(domain_name),
55+
allow_include=False,
56+
check_origin=False,
57+
relativize=False,
58+
)
59+
rrsets = []
60+
for name, rdataset in zone.iterate_rdatasets():
61+
rtype = dns.rdatatype.to_text(rdataset.rdtype)
62+
if rtype in _DNSSEC_TYPES:
63+
continue
64+
rrsets.append(
65+
{
66+
"name": name.to_text(),
67+
"type": rtype,
68+
"ttl": rdataset.ttl,
69+
"records": [rdata.to_text() for rdata in rdataset],
70+
}
71+
)
72+
return rrsets
73+
74+
75+
def rrsets_to_zonefile(domain_name: str, rrsets) -> str:
76+
lines = []
77+
for rrset in rrsets:
78+
name = rrset["name"]
79+
ttl = rrset["ttl"]
80+
rtype = rrset["type"]
81+
for record in rrset["records"]:
82+
lines.append(f"{name}\t{ttl}\tIN\t{rtype}\t{record}")
83+
return "\n".join(lines) + "\n"
84+
85+
86+
def get_csk_private_key(domain):
87+
if getattr(domain, "nslord", None) == "knot":
88+
return domain.get_csk_private_key()
89+
private_key = pdns.get_csk_private_key(domain.name)
90+
return private_key or domain.get_csk_private_key()
91+
92+
93+
def get_soa_serial(domain):
94+
name = domain.name.rstrip(".") + "."
95+
if getattr(domain, "nslord", None) == "knot":
96+
host = settings.NSLORD_KNOT_HOST
97+
port = settings.NSLORD_KNOT_PORT
98+
timeout = settings.NSLORD_KNOT_TIMEOUT
99+
else:
100+
host = "nslord"
101+
port = 53
102+
timeout = 5
103+
query = dns.message.make_query(name, dns.rdatatype.SOA)
104+
host = pdns.gethostbyname_cached(host)
105+
try:
106+
response = dns.query.tcp(query, host, port=port, timeout=timeout)
107+
except Exception:
108+
logger.warning("SOA serial query failed for %s", name, exc_info=True)
109+
return None
110+
rrset = response.get_rrset(
111+
dns.message.ANSWER,
112+
dns.name.from_text(name),
113+
dns.rdataclass.IN,
114+
dns.rdatatype.SOA,
115+
)
116+
if rrset is None:
117+
return None
118+
return rrset[0].serial

api/desecapi/pdns.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import json
22
import re
33
import socket
4+
import time
45
from functools import cache
56
from hashlib import sha1
67

8+
import dns.message
9+
import dns.name
10+
import dns.query
11+
import dns.rdataclass
12+
import dns.rdatatype
13+
import dns.rcode
714
import requests
815
from django.conf import settings
916
from django.core.exceptions import SuspiciousOperation
@@ -182,6 +189,24 @@ def delete_cryptokey(domain_name, key_id):
182189
_pdns_delete(NSLORD, "/zones/%s/cryptokeys/%s" % (pdns_id(domain_name), key_id))
183190

184191

192+
def get_csk_private_key(domain_name):
193+
keys = list_cryptokeys(domain_name)
194+
candidates = [
195+
key
196+
for key in keys
197+
if key.get("keytype") == "csk" or key.get("keytype") == "CSK"
198+
]
199+
if not candidates:
200+
candidates = keys
201+
for key in candidates:
202+
if not key.get("active", True):
203+
continue
204+
private_key = key.get("privatekey") or key.get("content")
205+
if private_key:
206+
return private_key
207+
return None
208+
209+
185210
def get_zone(domain):
186211
"""
187212
Retrieves a dict representation of the zone from pdns
@@ -309,6 +334,26 @@ def import_csk_key(name, *, dnskey, private_key):
309334
return cryptokey
310335

311336

337+
def import_zonefile_rrsets(name, rrsets):
338+
data = {
339+
"rrsets": [
340+
{
341+
"name": rrset["name"],
342+
"type": rrset["type"],
343+
"ttl": min(rrset["ttl"], settings.DEFAULT_NS_TTL),
344+
"changetype": "REPLACE",
345+
"records": [
346+
{"content": record, "disabled": False}
347+
for record in rrset["records"]
348+
],
349+
}
350+
for rrset in rrsets
351+
]
352+
}
353+
if data["rrsets"]:
354+
update_zone(name, data)
355+
356+
312357
def delete_zone(name, server):
313358
_pdns_delete(server, "/zones/" + pdns_id(name))
314359

@@ -329,6 +374,17 @@ def axfr_to_master(zone):
329374
_pdns_put(NSMASTER, "/zones/%s/axfr-retrieve" % pdns_id(zone))
330375

331376

377+
def wait_for_master_zone(zone, *, attempts=20, delay_seconds=0.5):
378+
zone_id = pdns_id(zone)
379+
for _ in range(attempts):
380+
try:
381+
_pdns_get(NSMASTER, "/zones/%s" % zone_id)
382+
return True
383+
except PDNSException:
384+
time.sleep(delay_seconds)
385+
return False
386+
387+
332388
def rectify_zone(name):
333389
_pdns_put(NSLORD, "/zones/%s/rectify" % pdns_id(name))
334390

0 commit comments

Comments
 (0)