pdns_change_tracker.py 15 KB

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