pdns_change_tracker.py 16 KB

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