pdns_change_tracker.py 16 KB

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