pdns_change_tracker.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import random
  2. import socket
  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 api import settings as api_settings
  7. from desecapi.models import RRset, RR, Domain
  8. from desecapi.pdns import _pdns_post, NSLORD, NSMASTER, _pdns_delete, _pdns_patch, _pdns_put, pdns_id
  9. class PDNSChangeTracker:
  10. """
  11. Hooks up to model signals to maintain two sets:
  12. - `domain_additions`: set of added domains
  13. - `domain_deletions`: set of deleted domains
  14. The two sets are guaranteed to be disjoint.
  15. Hooks up to model signals to maintain exactly three sets per domain:
  16. - `rr_set_additions`: set of added RR sets
  17. - `rr_set_modifications`: set of modified RR sets
  18. - `rr_set_deletions`: set of deleted RR sets
  19. `additions` and `deletions` are guaranteed to be disjoint:
  20. - If an item is in the set of additions while being deleted, it is removed from `rr_set_additions`.
  21. - If an item is in the set of deletions while being added, it is removed from `rr_set_deletions`.
  22. `modifications` and `deletions` are guaranteed to be disjoint.
  23. - If an item is in the set of deletions while being modified, an exception is raised.
  24. - If an item is in the set of modifications while being deleted, it is removed from `rr_set_modifications`.
  25. """
  26. class PDNSChange:
  27. """
  28. A reversible, atomic operation against the powerdns API.
  29. """
  30. def __init__(self, domain_name):
  31. self._domain_name = domain_name
  32. @property
  33. def domain_name(self):
  34. return self._domain_name
  35. @property
  36. def domain_name_normalized(self):
  37. return self._domain_name + '.'
  38. @property
  39. def domain_pdns_id(self):
  40. return pdns_id(self._domain_name)
  41. @property
  42. def axfr_required(self):
  43. raise NotImplementedError()
  44. def pdns_do(self):
  45. raise NotImplementedError()
  46. def api_do(self):
  47. raise NotImplementedError()
  48. class CreateDomain(PDNSChange):
  49. @property
  50. def axfr_required(self):
  51. return True
  52. def pdns_do(self):
  53. salt = '%016x' % random.randrange(16 ** 16)
  54. _pdns_post(
  55. NSLORD, '/zones',
  56. {
  57. 'name': self.domain_name_normalized,
  58. 'kind': 'MASTER',
  59. 'dnssec': True,
  60. 'nsec3param': '1 0 127 %s' % salt,
  61. 'nameservers': api_settings.DEFAULT_NS
  62. }
  63. )
  64. _pdns_post(
  65. NSMASTER, '/zones',
  66. {
  67. 'name': self.domain_name_normalized,
  68. 'kind': 'SLAVE',
  69. 'masters': [socket.gethostbyname('nslord')]
  70. }
  71. )
  72. def api_do(self):
  73. rr_set = RRset(
  74. domain=Domain.objects.get(name=self.domain_name),
  75. type='NS', subname='',
  76. ttl=3600, # TODO configure this via env settings
  77. )
  78. rr_set.save()
  79. rrs = [RR(rrset=rr_set, content=ns) for ns in api_settings.DEFAULT_NS]
  80. RR.objects.bulk_create(rrs) # One INSERT
  81. class DeleteDomain(PDNSChange):
  82. @property
  83. def axfr_required(self):
  84. return False
  85. def pdns_do(self):
  86. _pdns_delete(NSLORD, '/zones/' + self.domain_pdns_id)
  87. _pdns_delete(NSMASTER, '/zones/' + self.domain_pdns_id)
  88. def api_do(self):
  89. pass
  90. class CreateUpdateDeleteRRSets(PDNSChange):
  91. def __init__(self, domain_name, additions, modifications, deletions):
  92. super().__init__(domain_name)
  93. self._additions = additions
  94. self._modifications = modifications
  95. self._deletions = deletions
  96. @property
  97. def axfr_required(self):
  98. return True
  99. def pdns_do(self):
  100. data = {
  101. 'rrsets':
  102. [
  103. {
  104. 'name': RRset.construct_name(subname, self._domain_name),
  105. 'type': type_,
  106. 'ttl': RRset.objects.values_list('ttl', flat=True).get(domain__name=self._domain_name,
  107. type=type_, subname=subname),
  108. 'changetype': 'REPLACE',
  109. 'records': [
  110. {'content': rr.content, 'disabled': False}
  111. for rr in RR.objects.filter(
  112. rrset__domain__name=self._domain_name,
  113. rrset__type=type_,
  114. rrset__subname=subname)
  115. ]
  116. }
  117. for type_, subname in (self._additions | self._modifications) - self._deletions
  118. ] + [
  119. {
  120. 'name': RRset.construct_name(subname, self._domain_name),
  121. 'type': type_,
  122. 'changetype': 'DELETE',
  123. 'records': []
  124. }
  125. for type_, subname in self._deletions
  126. ]
  127. }
  128. if data['rrsets']:
  129. _pdns_patch(NSLORD, '/zones/' + self.domain_pdns_id, data)
  130. def api_do(self):
  131. pass
  132. def __init__(self):
  133. self._domain_additions = set()
  134. self._domain_deletions = set()
  135. self._rr_set_additions = {}
  136. self._rr_set_modifications = {}
  137. self._rr_set_deletions = {}
  138. self.transaction = None
  139. def _manage_signals(self, method):
  140. if method not in ['connect', 'disconnect']:
  141. raise ValueError()
  142. getattr(post_save, method)(self._on_rr_post_save, sender=RR)
  143. getattr(post_delete, method)(self._on_rr_post_delete, sender=RR)
  144. getattr(post_save, method)(self._on_rr_set_post_save, sender=RRset)
  145. getattr(post_delete, method)(self._on_rr_set_post_delete, sender=RRset)
  146. getattr(post_save, method)(self._on_domain_post_save, sender=Domain)
  147. getattr(post_delete, method)(self._on_domain_post_delete, sender=Domain)
  148. def __enter__(self):
  149. self._domain_additions = set()
  150. self._domain_deletions = set()
  151. self._rr_set_additions = {}
  152. self._rr_set_modifications = {}
  153. self._rr_set_deletions = {}
  154. self._manage_signals('connect')
  155. self.transaction = atomic()
  156. self.transaction.__enter__()
  157. def __exit__(self, exc_type, exc_val, exc_tb):
  158. self._manage_signals('disconnect')
  159. if exc_type:
  160. # An exception occurred inside our context, exit db transaction and dismiss pdns changes
  161. self.transaction.__exit__(exc_type, exc_val, exc_tb)
  162. return
  163. # TODO introduce two phase commit protocol
  164. changes = self._compute_changes()
  165. axfr_required = set()
  166. for change in changes:
  167. try:
  168. change.pdns_do()
  169. change.api_do()
  170. if change.axfr_required:
  171. axfr_required.add(change.domain_name)
  172. except Exception as e:
  173. # TODO gather as much info as possible
  174. # see if pdns and api are possibly in an inconsistent state
  175. self.transaction.__exit__(type(e), e, e.__traceback__)
  176. raise e
  177. self.transaction.__exit__(None, None, None)
  178. for name in axfr_required:
  179. _pdns_put(NSMASTER, '/zones/%s/axfr-retrieve' % pdns_id(name))
  180. Domain.objects.filter(name__in=axfr_required).update(published=timezone.now())
  181. def _compute_changes(self):
  182. changes = []
  183. for domain_name in self._domain_deletions:
  184. # discard any RR set modifications
  185. self._rr_set_additions.pop(domain_name, None)
  186. self._rr_set_modifications.pop(domain_name, None)
  187. self._rr_set_deletions.pop(domain_name, None)
  188. changes.append(PDNSChangeTracker.DeleteDomain(domain_name))
  189. for domain_name in self._rr_set_additions.keys() | self._domain_additions:
  190. if domain_name in self._domain_additions:
  191. changes.append(PDNSChangeTracker.CreateDomain(domain_name))
  192. additions = self._rr_set_additions.get(domain_name, set())
  193. modifications = self._rr_set_modifications.get(domain_name, set())
  194. deletions = self._rr_set_deletions.get(domain_name, set())
  195. assert not (additions & deletions)
  196. assert not (modifications & deletions)
  197. # Due to disjoint guarantees with `deletions`, we have four types of RR sets:
  198. # (1) purely added RR sets
  199. # (2) purely modified RR sets
  200. # (3) added and modified RR sets
  201. # (4) purely deleted RR sets
  202. # We send RR sets to PDNS if one of the following conditions holds:
  203. # (a) RR set was added and has at least one RR
  204. # (b) RR set was modified
  205. # (c) RR set was deleted
  206. # Conditions (b) and (c) are already covered in the modifications and deletions list,
  207. # we filter the additions list to remove newly-added, but empty RR sets
  208. additions -= {
  209. (type_, subname) for (type_, subname) in additions
  210. if not RR.objects.filter(
  211. rrset__domain__name=domain_name,
  212. rrset__type=type_,
  213. rrset__subname=subname).exists()
  214. }
  215. if additions | modifications | deletions:
  216. changes.append(PDNSChangeTracker.CreateUpdateDeleteRRSets(
  217. domain_name, additions, modifications, deletions))
  218. return changes
  219. def _rr_set_updated(self, rr_set: RRset, deleted=False, created=False):
  220. if self._rr_set_modifications.get(rr_set.domain.name, None) is None:
  221. self._rr_set_additions[rr_set.domain.name] = set()
  222. self._rr_set_modifications[rr_set.domain.name] = set()
  223. self._rr_set_deletions[rr_set.domain.name] = set()
  224. additions = self._rr_set_additions[rr_set.domain.name]
  225. modifications = self._rr_set_modifications[rr_set.domain.name]
  226. deletions = self._rr_set_deletions[rr_set.domain.name]
  227. item = (rr_set.type, rr_set.subname)
  228. if created:
  229. additions.add(item)
  230. assert item not in modifications
  231. deletions.discard(item)
  232. elif deleted:
  233. if item in additions:
  234. additions.remove(item)
  235. modifications.discard(item)
  236. # no change to deletions
  237. else:
  238. # item not in additions
  239. modifications.discard(item)
  240. deletions.add(item)
  241. elif not created and not deleted:
  242. # we don't care if item was created or not
  243. modifications.add(item)
  244. assert item not in deletions
  245. else:
  246. raise ValueError('An RR set cannot be created and deleted at the same time.')
  247. def _domain_updated(self, domain: Domain, created=False, deleted=False):
  248. if not created and not deleted:
  249. # NOTE that the name must not be changed by API contract with models, hence here no-op for pdns.
  250. return
  251. name = domain.name
  252. additions = self._domain_additions
  253. deletions = self._domain_deletions
  254. if created and deleted:
  255. raise ValueError('A domain set cannot be created and deleted at the same time.')
  256. if created:
  257. if name in deletions:
  258. deletions.remove(name)
  259. else:
  260. additions.add(name)
  261. elif deleted:
  262. if name in additions:
  263. additions.remove(name)
  264. else:
  265. deletions.add(name)
  266. # noinspection PyUnusedLocal
  267. def _on_rr_post_save(self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs):
  268. self._rr_set_updated(instance.rrset)
  269. # noinspection PyUnusedLocal
  270. def _on_rr_post_delete(self, signal, sender, instance: RR, using, **kwargs):
  271. self._rr_set_updated(instance.rrset)
  272. # noinspection PyUnusedLocal
  273. def _on_rr_set_post_save(self, signal, sender, instance: RRset, created, update_fields, raw, using, **kwargs):
  274. self._rr_set_updated(instance, created=created)
  275. # noinspection PyUnusedLocal
  276. def _on_rr_set_post_delete(self, signal, sender, instance: RRset, using, **kwargs):
  277. self._rr_set_updated(instance, deleted=True)
  278. # noinspection PyUnusedLocal
  279. def _on_domain_post_save(self, signal, sender, instance: Domain, created, update_fields, raw, using, **kwargs):
  280. self._domain_updated(instance, created=created)
  281. # noinspection PyUnusedLocal
  282. def _on_domain_post_delete(self, signal, sender, instance: Domain, using, **kwargs):
  283. self._domain_updated(instance, deleted=True)
  284. def __str__(self):
  285. all_rr_sets = self._rr_set_additions.keys() | self._rr_set_modifications.keys() | self._rr_set_deletions.keys()
  286. all_domains = self._domain_additions | self._domain_deletions
  287. return '<%s: %i added or deleted domains; %i added, modified or deleted RR sets>' % (
  288. self.__class__.__name__,
  289. len(all_domains),
  290. len(all_rr_sets)
  291. )