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
  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: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
  82. # SOA retry = refresh
  83. # SOA expire: 4 weeks (all signatures will have expired anyways)
  84. # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
  85. 'content': 'set.an.example. get.desec.io. 1 86400 86400 2419200 3600',
  86. 'disabled': False
  87. }],
  88. }],
  89. }
  90. )
  91. _pdns_post(
  92. NSMASTER, '/zones?rrsets=false',
  93. {
  94. 'name': self.domain_name_normalized,
  95. 'kind': 'SLAVE',
  96. 'masters': [socket.gethostbyname('nslord')]
  97. }
  98. )
  99. self.update_catalog()
  100. def api_do(self):
  101. rr_set = RRset(
  102. domain=Domain.objects.get(name=self.domain_name),
  103. type='NS', subname='',
  104. ttl=settings.DEFAULT_NS_TTL,
  105. )
  106. rr_set.save()
  107. rrs = [RR(rrset=rr_set, content=ns) for ns in settings.DEFAULT_NS]
  108. RR.objects.bulk_create(rrs) # One INSERT
  109. def __str__(self):
  110. return 'Create Domain %s' % self.domain_name
  111. class DeleteDomain(PDNSChange):
  112. @property
  113. def axfr_required(self):
  114. return False
  115. def pdns_do(self):
  116. _pdns_delete(NSLORD, '/zones/' + self.domain_pdns_id)
  117. _pdns_delete(NSMASTER, '/zones/' + self.domain_pdns_id)
  118. self.update_catalog(delete=True)
  119. def api_do(self):
  120. pass
  121. def __str__(self):
  122. return 'Delete Domain %s' % self.domain_name
  123. class CreateUpdateDeleteRRSets(PDNSChange):
  124. def __init__(self, domain_name, additions, modifications, deletions):
  125. super().__init__(domain_name)
  126. self._additions = additions
  127. self._modifications = modifications
  128. self._deletions = deletions
  129. @property
  130. def axfr_required(self):
  131. return True
  132. def pdns_do(self):
  133. data = {
  134. 'rrsets':
  135. [
  136. {
  137. 'name': RRset.construct_name(subname, self._domain_name),
  138. 'type': type_,
  139. 'ttl': 1, # some meaningless integer required by pdns's syntax
  140. 'changetype': 'REPLACE', # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
  141. 'records': []
  142. }
  143. for type_, subname in self._deletions
  144. ] + [
  145. {
  146. 'name': RRset.construct_name(subname, self._domain_name),
  147. 'type': type_,
  148. 'ttl': RRset.objects.values_list('ttl', flat=True).get(domain__name=self._domain_name,
  149. type=type_, subname=subname),
  150. 'changetype': 'REPLACE',
  151. 'records': [
  152. {'content': rr.content, 'disabled': False}
  153. for rr in RR.objects.filter(
  154. rrset__domain__name=self._domain_name,
  155. rrset__type=type_,
  156. rrset__subname=subname)
  157. ]
  158. }
  159. for type_, subname in (self._additions | self._modifications) - self._deletions
  160. ]
  161. }
  162. if data['rrsets']:
  163. _pdns_patch(NSLORD, '/zones/' + self.domain_pdns_id, data)
  164. def api_do(self):
  165. pass
  166. def __str__(self):
  167. return 'Update RRsets of %s: additions=%s, modifications=%s, deletions=%s' % \
  168. (self.domain_name, list(self._additions), list(self._modifications), list(self._deletions))
  169. def __init__(self):
  170. self._domain_additions = set()
  171. self._domain_deletions = set()
  172. self._rr_set_additions = {}
  173. self._rr_set_modifications = {}
  174. self._rr_set_deletions = {}
  175. self.transaction = None
  176. @classmethod
  177. def track(cls, f):
  178. """
  179. Execute function f with the change tracker.
  180. :param f: Function to be tracked for PDNS-relevant changes.
  181. :return: Returns the return value of f.
  182. """
  183. with cls():
  184. return f()
  185. def _manage_signals(self, method):
  186. if method not in ['connect', 'disconnect']:
  187. raise ValueError()
  188. getattr(post_save, method)(self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__)
  189. getattr(post_delete, method)(self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__)
  190. getattr(post_save, method)(self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__)
  191. getattr(post_delete, method)(self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__)
  192. getattr(post_save, method)(self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__)
  193. getattr(post_delete, method)(self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__)
  194. def __enter__(self):
  195. PDNSChangeTracker._active_change_trackers += 1
  196. assert PDNSChangeTracker._active_change_trackers == 1, 'Nesting %s is not supported.' % self.__class__.__name__
  197. self._domain_additions = set()
  198. self._domain_deletions = set()
  199. self._rr_set_additions = {}
  200. self._rr_set_modifications = {}
  201. self._rr_set_deletions = {}
  202. self._manage_signals('connect')
  203. self.transaction = atomic()
  204. self.transaction.__enter__()
  205. def __exit__(self, exc_type, exc_val, exc_tb):
  206. PDNSChangeTracker._active_change_trackers -= 1
  207. self._manage_signals('disconnect')
  208. if exc_type:
  209. # An exception occurred inside our context, exit db transaction and dismiss pdns changes
  210. self.transaction.__exit__(exc_type, exc_val, exc_tb)
  211. return
  212. # TODO introduce two phase commit protocol
  213. changes = self._compute_changes()
  214. axfr_required = set()
  215. for change in changes:
  216. try:
  217. change.pdns_do()
  218. change.api_do()
  219. if change.axfr_required:
  220. axfr_required.add(change.domain_name)
  221. except PDNSValidationError as e:
  222. self.transaction.__exit__(type(e), e, e.__traceback__)
  223. raise e
  224. except Exception as e:
  225. self.transaction.__exit__(type(e), e, e.__traceback__)
  226. exc = ValueError(f'For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}')
  227. raise exc from e
  228. self.transaction.__exit__(None, None, None)
  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. )