pdns_change_tracker.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import socket
  2. from django.conf import settings
  3. from django.db.models.signals import post_save, post_delete
  4. from django.db.transaction import atomic
  5. from django.utils import timezone
  6. from desecapi import metrics, pch
  7. from desecapi.models import RRset, RR, Domain
  8. from desecapi.pdns import (
  9. _pdns_post,
  10. NSLORD,
  11. NSMASTER,
  12. _pdns_delete,
  13. _pdns_patch,
  14. _pdns_put,
  15. pdns_id,
  16. construct_catalog_rrset,
  17. )
  18. class PDNSChangeTracker:
  19. """
  20. Hooks up to model signals to maintain two sets:
  21. - `domain_additions`: set of added domains
  22. - `domain_deletions`: set of deleted domains
  23. The two sets are guaranteed to be disjoint.
  24. Hooks up to model signals to maintain exactly three sets per domain:
  25. - `rr_set_additions`: set of added RR sets
  26. - `rr_set_modifications`: set of modified RR sets
  27. - `rr_set_deletions`: set of deleted RR sets
  28. `additions` and `deletions` are guaranteed to be disjoint:
  29. - If an item is in the set of additions while being deleted, it is removed from `rr_set_additions`.
  30. - If an item is in the set of deletions while being added, it is removed from `rr_set_deletions`.
  31. `modifications` and `deletions` are guaranteed to be disjoint.
  32. - If an item is in the set of deletions while being modified, an exception is raised.
  33. - If an item is in the set of modifications while being deleted, it is removed from `rr_set_modifications`.
  34. Note every change tracker object will track all changes to the model across threading.
  35. To avoid side-effects, it is recommended that in each Django process, only one change
  36. tracker is run at a time, i.e. do not use them in parallel (e.g., in a multi-threading
  37. scenario), do not use them nested.
  38. """
  39. _active_change_trackers = 0
  40. class PDNSChange:
  41. """
  42. A reversible, atomic operation against the powerdns API.
  43. """
  44. def __init__(self, domain_name):
  45. self._domain_name = domain_name
  46. @property
  47. def domain_name(self):
  48. return self._domain_name
  49. @property
  50. def domain_name_normalized(self):
  51. return self._domain_name + "."
  52. @property
  53. def domain_pdns_id(self):
  54. return pdns_id(self._domain_name)
  55. @property
  56. def axfr_required(self):
  57. raise NotImplementedError()
  58. def pdns_do(self):
  59. raise NotImplementedError()
  60. def api_do(self):
  61. raise NotImplementedError()
  62. def pch_do(self):
  63. raise NotImplementedError()
  64. def update_catalog(self, delete=False):
  65. content = _pdns_patch(
  66. NSMASTER,
  67. "/zones/" + pdns_id(settings.CATALOG_ZONE),
  68. {
  69. "rrsets": [
  70. construct_catalog_rrset(zone=self.domain_name, delete=delete)
  71. ]
  72. },
  73. )
  74. metrics.get("desecapi_pdns_catalog_updated").inc()
  75. return content
  76. class CreateDomain(PDNSChange):
  77. @property
  78. def axfr_required(self):
  79. return True
  80. def pdns_do(self):
  81. _pdns_post(
  82. NSLORD,
  83. "/zones?rrsets=false",
  84. {
  85. "name": self.domain_name_normalized,
  86. "kind": "MASTER",
  87. "dnssec": True,
  88. "nsec3param": "1 0 0 -",
  89. "nameservers": settings.DEFAULT_NS,
  90. "rrsets": [
  91. {
  92. "name": self.domain_name_normalized,
  93. "type": "SOA",
  94. # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
  95. "ttl": 300,
  96. "records": [
  97. {
  98. # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
  99. # SOA retry = 1h
  100. # SOA expire: 4 weeks (all signatures will have expired anyways)
  101. # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
  102. "content": "get.desec.io. get.desec.io. 1 86400 3600 2419200 3600",
  103. "disabled": False,
  104. }
  105. ],
  106. }
  107. ],
  108. },
  109. )
  110. _pdns_post(
  111. NSMASTER,
  112. "/zones?rrsets=false",
  113. {
  114. "name": self.domain_name_normalized,
  115. "kind": "SLAVE",
  116. "masters": [socket.gethostbyname("nslord")],
  117. "master_tsig_key_ids": ["default"],
  118. },
  119. )
  120. self.update_catalog()
  121. def api_do(self):
  122. rr_set = RRset(
  123. domain=Domain.objects.get(name=self.domain_name),
  124. type="NS",
  125. subname="",
  126. ttl=settings.DEFAULT_NS_TTL,
  127. )
  128. rr_set.save()
  129. rrs = [RR(rrset=rr_set, content=ns) for ns in settings.DEFAULT_NS]
  130. RR.objects.bulk_create(rrs) # One INSERT
  131. def pch_do(self):
  132. pch.create_domains([self.domain_name])
  133. def __str__(self):
  134. return "Create Domain %s" % self.domain_name
  135. class DeleteDomain(PDNSChange):
  136. @property
  137. def axfr_required(self):
  138. return False
  139. def pdns_do(self):
  140. _pdns_delete(NSLORD, "/zones/" + self.domain_pdns_id)
  141. _pdns_delete(NSMASTER, "/zones/" + self.domain_pdns_id)
  142. self.update_catalog(delete=True)
  143. def api_do(self):
  144. pass
  145. def pch_do(self):
  146. pch.delete_domains([self.domain_name])
  147. def __str__(self):
  148. return "Delete Domain %s" % self.domain_name
  149. class CreateUpdateDeleteRRSets(PDNSChange):
  150. def __init__(self, domain_name, additions, modifications, deletions):
  151. super().__init__(domain_name)
  152. self._additions = additions
  153. self._modifications = modifications
  154. self._deletions = deletions
  155. @property
  156. def axfr_required(self):
  157. return True
  158. def pdns_do(self):
  159. data = {
  160. "rrsets": [
  161. {
  162. "name": RRset.construct_name(subname, self._domain_name),
  163. "type": type_,
  164. "ttl": 1, # some meaningless integer required by pdns's syntax
  165. "changetype": "REPLACE", # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
  166. "records": [],
  167. }
  168. for type_, subname in self._deletions
  169. ]
  170. + [
  171. {
  172. "name": RRset.construct_name(subname, self._domain_name),
  173. "type": type_,
  174. "ttl": RRset.objects.values_list("ttl", flat=True).get(
  175. domain__name=self._domain_name, type=type_, subname=subname
  176. ),
  177. "changetype": "REPLACE",
  178. "records": [
  179. {"content": rr.content, "disabled": False}
  180. for rr in RR.objects.filter(
  181. rrset__domain__name=self._domain_name,
  182. rrset__type=type_,
  183. rrset__subname=subname,
  184. )
  185. ],
  186. }
  187. for type_, subname in (self._additions | self._modifications)
  188. - self._deletions
  189. ]
  190. }
  191. if data["rrsets"]:
  192. _pdns_patch(NSLORD, "/zones/" + self.domain_pdns_id, data)
  193. def api_do(self):
  194. pass
  195. def pch_do(self):
  196. pass
  197. def __str__(self):
  198. return (
  199. "Update RRsets of %s: additions=%s, modifications=%s, deletions=%s"
  200. % (
  201. self.domain_name,
  202. list(self._additions),
  203. list(self._modifications),
  204. list(self._deletions),
  205. )
  206. )
  207. def __init__(self):
  208. self._domain_additions = set()
  209. self._domain_deletions = set()
  210. self._rr_set_additions = {}
  211. self._rr_set_modifications = {}
  212. self._rr_set_deletions = {}
  213. self.transaction = None
  214. @classmethod
  215. def track(cls, f):
  216. """
  217. Execute function f with the change tracker.
  218. :param f: Function to be tracked for PDNS-relevant changes.
  219. :return: Returns the return value of f.
  220. """
  221. with cls():
  222. return f()
  223. def _manage_signals(self, method):
  224. if method not in ["connect", "disconnect"]:
  225. raise ValueError()
  226. getattr(post_save, method)(
  227. self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__
  228. )
  229. getattr(post_delete, method)(
  230. self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__
  231. )
  232. getattr(post_save, method)(
  233. self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__
  234. )
  235. getattr(post_delete, method)(
  236. self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__
  237. )
  238. getattr(post_save, method)(
  239. self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__
  240. )
  241. getattr(post_delete, method)(
  242. self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__
  243. )
  244. def __enter__(self):
  245. PDNSChangeTracker._active_change_trackers += 1
  246. assert PDNSChangeTracker._active_change_trackers == 1, (
  247. "Nesting %s is not supported." % self.__class__.__name__
  248. )
  249. self._domain_additions = set()
  250. self._domain_deletions = set()
  251. self._rr_set_additions = {}
  252. self._rr_set_modifications = {}
  253. self._rr_set_deletions = {}
  254. self._manage_signals("connect")
  255. self.transaction = atomic()
  256. self.transaction.__enter__()
  257. def __exit__(self, exc_type, exc_val, exc_tb):
  258. PDNSChangeTracker._active_change_trackers -= 1
  259. self._manage_signals("disconnect")
  260. if exc_type:
  261. # An exception occurred inside our context, exit db transaction and dismiss pdns changes
  262. self.transaction.__exit__(exc_type, exc_val, exc_tb)
  263. return
  264. # TODO introduce two phase commit protocol
  265. changes = self._compute_changes()
  266. axfr_required = set()
  267. for change in changes:
  268. try:
  269. change.pdns_do()
  270. change.api_do()
  271. if settings.PCH_API and not settings.DEBUG:
  272. change.pch_do()
  273. if change.axfr_required:
  274. axfr_required.add(change.domain_name)
  275. except Exception as e:
  276. self.transaction.__exit__(type(e), e, e.__traceback__)
  277. exc = ValueError(
  278. f"For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}"
  279. )
  280. raise exc from e
  281. self.transaction.__exit__(None, None, None)
  282. for name in axfr_required:
  283. _pdns_put(NSMASTER, "/zones/%s/axfr-retrieve" % pdns_id(name))
  284. Domain.objects.filter(name__in=axfr_required).update(published=timezone.now())
  285. def _compute_changes(self):
  286. changes = []
  287. for domain_name in self._domain_deletions:
  288. # discard any RR set modifications
  289. self._rr_set_additions.pop(domain_name, None)
  290. self._rr_set_modifications.pop(domain_name, None)
  291. self._rr_set_deletions.pop(domain_name, None)
  292. changes.append(PDNSChangeTracker.DeleteDomain(domain_name))
  293. for domain_name in self._rr_set_additions.keys() | self._domain_additions:
  294. if domain_name in self._domain_additions:
  295. changes.append(PDNSChangeTracker.CreateDomain(domain_name))
  296. additions = self._rr_set_additions.get(domain_name, set())
  297. modifications = self._rr_set_modifications.get(domain_name, set())
  298. deletions = self._rr_set_deletions.get(domain_name, set())
  299. assert not (additions & deletions)
  300. assert not (modifications & deletions)
  301. # Due to disjoint guarantees with `deletions`, we have four types of RR sets:
  302. # (1) purely added RR sets
  303. # (2) purely modified RR sets
  304. # (3) added and modified RR sets
  305. # (4) purely deleted RR sets
  306. # We send RR sets to PDNS if one of the following conditions holds:
  307. # (a) RR set was added and has at least one RR
  308. # (b) RR set was modified
  309. # (c) RR set was deleted
  310. # Conditions (b) and (c) are already covered in the modifications and deletions list,
  311. # we filter the additions list to remove newly-added, but empty RR sets
  312. additions -= {
  313. (type_, subname)
  314. for (type_, subname) in additions
  315. if not RR.objects.filter(
  316. rrset__domain__name=domain_name,
  317. rrset__type=type_,
  318. rrset__subname=subname,
  319. ).exists()
  320. }
  321. if additions | modifications | deletions:
  322. changes.append(
  323. PDNSChangeTracker.CreateUpdateDeleteRRSets(
  324. domain_name, additions, modifications, deletions
  325. )
  326. )
  327. return changes
  328. def _rr_set_updated(self, rr_set: RRset, deleted=False, created=False):
  329. if self._rr_set_modifications.get(rr_set.domain.name, None) is None:
  330. self._rr_set_additions[rr_set.domain.name] = set()
  331. self._rr_set_modifications[rr_set.domain.name] = set()
  332. self._rr_set_deletions[rr_set.domain.name] = set()
  333. additions = self._rr_set_additions[rr_set.domain.name]
  334. modifications = self._rr_set_modifications[rr_set.domain.name]
  335. deletions = self._rr_set_deletions[rr_set.domain.name]
  336. item = (rr_set.type, rr_set.subname)
  337. match (created, deleted):
  338. case (True, False): # created
  339. additions.add(item)
  340. # can fail with concurrent deletion request
  341. assert item not in modifications
  342. deletions.discard(item)
  343. case (False, True): # deleted
  344. if item in additions:
  345. additions.remove(item)
  346. modifications.discard(item)
  347. # no change to deletions
  348. else:
  349. # item not in additions
  350. modifications.discard(item)
  351. deletions.add(item)
  352. case (False, False): # modified
  353. # we don't care if item was created or not
  354. modifications.add(item)
  355. assert item not in deletions
  356. case _:
  357. raise ValueError(
  358. "An RR set cannot be created and deleted at the same time."
  359. )
  360. def _domain_updated(self, domain: Domain, created=False, deleted=False):
  361. if not created and not deleted:
  362. # NOTE that the name must not be changed by API contract with models, hence here no-op for pdns.
  363. return
  364. name = domain.name
  365. additions = self._domain_additions
  366. deletions = self._domain_deletions
  367. if created and deleted:
  368. raise ValueError(
  369. "A domain set cannot be created and deleted at the same time."
  370. )
  371. if created:
  372. if name in deletions:
  373. deletions.remove(name)
  374. else:
  375. additions.add(name)
  376. elif deleted:
  377. if name in additions:
  378. additions.remove(name)
  379. else:
  380. deletions.add(name)
  381. # noinspection PyUnusedLocal
  382. def _on_rr_post_save(
  383. self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs
  384. ):
  385. self._rr_set_updated(instance.rrset)
  386. # noinspection PyUnusedLocal
  387. def _on_rr_post_delete(self, signal, sender, instance: RR, using, **kwargs):
  388. try:
  389. self._rr_set_updated(instance.rrset)
  390. except RRset.DoesNotExist:
  391. pass
  392. # noinspection PyUnusedLocal
  393. def _on_rr_set_post_save(
  394. self,
  395. signal,
  396. sender,
  397. instance: RRset,
  398. created,
  399. update_fields,
  400. raw,
  401. using,
  402. **kwargs,
  403. ):
  404. self._rr_set_updated(instance, created=created)
  405. # noinspection PyUnusedLocal
  406. def _on_rr_set_post_delete(self, signal, sender, instance: RRset, using, **kwargs):
  407. self._rr_set_updated(instance, deleted=True)
  408. # noinspection PyUnusedLocal
  409. def _on_domain_post_save(
  410. self,
  411. signal,
  412. sender,
  413. instance: Domain,
  414. created,
  415. update_fields,
  416. raw,
  417. using,
  418. **kwargs,
  419. ):
  420. self._domain_updated(instance, created=created)
  421. # noinspection PyUnusedLocal
  422. def _on_domain_post_delete(self, signal, sender, instance: Domain, using, **kwargs):
  423. self._domain_updated(instance, deleted=True)
  424. def __str__(self):
  425. all_rr_sets = (
  426. self._rr_set_additions.keys()
  427. | self._rr_set_modifications.keys()
  428. | self._rr_set_deletions.keys()
  429. )
  430. all_domains = self._domain_additions | self._domain_deletions
  431. return (
  432. "<%s: %i added or deleted domains; %i added, modified or deleted RR sets>"
  433. % (self.__class__.__name__, len(all_domains), len(all_rr_sets))
  434. )