records.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. from __future__ import annotations
  2. import binascii
  3. import uuid
  4. import dns
  5. from django.contrib.postgres.constraints import ExclusionConstraint
  6. from django.contrib.postgres.fields import RangeOperators
  7. from django.core import validators
  8. from django.core.exceptions import ValidationError
  9. from django.db import models
  10. from django.db.models import Manager
  11. from django.db.models.expressions import RawSQL
  12. from django_prometheus.models import ExportModelOperationsMixin
  13. from dns import rdataclass, rdatatype
  14. from dns.rdtypes import ANY, IN
  15. from desecapi import pdns
  16. from desecapi.dns import AAAA, CERT, LongQuotedTXT, MX, NS, SRV
  17. from .base import validate_lower, validate_upper
  18. # RR set types: the good, the bad, and the ugly
  19. # known, but unsupported types
  20. RR_SET_TYPES_UNSUPPORTED = {
  21. 'ALIAS', # Requires signing at the frontend, hence unsupported in desec-stack
  22. 'IPSECKEY', # broken in pdns, https://github.com/PowerDNS/pdns/issues/10589 TODO enable with pdns auth >= 4.7.0
  23. 'KEY', # Application use restricted by RFC 3445, DNSSEC use replaced by DNSKEY and handled automatically
  24. 'WKS', # General usage not recommended, "SHOULD NOT" be used in SMTP (RFC 1123)
  25. }
  26. # restricted types are managed in use by the API, and cannot directly be modified by the API client
  27. RR_SET_TYPES_AUTOMATIC = {
  28. # corresponding functionality is automatically managed:
  29. 'KEY', 'NSEC', 'NSEC3', 'OPT', 'RRSIG',
  30. # automatically managed by the API:
  31. 'NSEC3PARAM', 'SOA'
  32. }
  33. # backend types are types that are the types supported by the backend(s)
  34. RR_SET_TYPES_BACKEND = pdns.SUPPORTED_RRSET_TYPES
  35. # validation types are types supported by the validation backend, currently: dnspython
  36. RR_SET_TYPES_VALIDATION = set(ANY.__all__) | set(IN.__all__) \
  37. | {'L32', 'L64', 'LP', 'NID'} # https://github.com/rthalley/dnspython/pull/751
  38. # manageable types are directly managed by the API client
  39. RR_SET_TYPES_MANAGEABLE = \
  40. (RR_SET_TYPES_BACKEND & RR_SET_TYPES_VALIDATION) - RR_SET_TYPES_UNSUPPORTED - RR_SET_TYPES_AUTOMATIC
  41. class RRsetManager(Manager):
  42. def create(self, contents=None, **kwargs):
  43. rrset = super().create(**kwargs)
  44. for content in contents or []:
  45. RR.objects.create(rrset=rrset, content=content)
  46. return rrset
  47. class RRset(ExportModelOperationsMixin('RRset'), models.Model):
  48. id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  49. created = models.DateTimeField(auto_now_add=True)
  50. touched = models.DateTimeField(auto_now=True, db_index=True)
  51. domain = models.ForeignKey('Domain', on_delete=models.CASCADE)
  52. subname = models.CharField(
  53. max_length=178,
  54. blank=True,
  55. validators=[
  56. validate_lower,
  57. validators.RegexValidator(
  58. regex=r'^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$',
  59. message='Subname can only use (lowercase) a-z, 0-9, ., -, and _, '
  60. 'may start with a \'*.\', or just be \'*\'. Components may not exceed 63 characters.',
  61. code='invalid_subname'
  62. )
  63. ]
  64. )
  65. type = models.CharField(
  66. max_length=10,
  67. validators=[
  68. validate_upper,
  69. validators.RegexValidator(
  70. regex=r'^[A-Z][A-Z0-9]*$',
  71. message='Type must be uppercase alphanumeric and start with a letter.',
  72. code='invalid_type'
  73. )
  74. ]
  75. )
  76. ttl = models.PositiveIntegerField()
  77. objects = RRsetManager()
  78. class Meta:
  79. constraints = [
  80. ExclusionConstraint(
  81. name='cname_exclusivity',
  82. expressions=[
  83. ('domain', RangeOperators.EQUAL),
  84. ('subname', RangeOperators.EQUAL),
  85. (RawSQL("int4(type = 'CNAME')", ()), RangeOperators.NOT_EQUAL),
  86. ],
  87. ),
  88. ]
  89. unique_together = (("domain", "subname", "type"),)
  90. @staticmethod
  91. def construct_name(subname, domain_name):
  92. return '.'.join(filter(None, [subname, domain_name])) + '.'
  93. @property
  94. def name(self):
  95. return self.construct_name(self.subname, self.domain.name)
  96. def save(self, *args, **kwargs):
  97. # TODO Enforce that subname and type aren't changed. https://github.com/desec-io/desec-stack/issues/553
  98. self.full_clean(validate_unique=False)
  99. super().save(*args, **kwargs)
  100. def clean_records(self, records_presentation_format):
  101. """
  102. Validates the records belonging to this set. Validation rules follow the DNS specification; some types may
  103. incur additional validation rules.
  104. Raises ValidationError if violation of DNS specification is found.
  105. Returns a set of records in canonical presentation format.
  106. :param records_presentation_format: iterable of records in presentation format
  107. """
  108. errors = []
  109. # Singletons
  110. if self.type in ('CNAME', 'DNAME',):
  111. if len(records_presentation_format) > 1:
  112. errors.append(f'{self.type} RRset cannot have multiple records.')
  113. # Non-apex
  114. if self.type in ('CNAME', 'DS',):
  115. if self.subname == '':
  116. errors.append(f'{self.type} RRset cannot have empty subname.')
  117. if self.type in ('DNSKEY',):
  118. if self.subname != '':
  119. errors.append(f'{self.type} RRset must have empty subname.')
  120. def _error_msg(record, detail):
  121. return f'Record content of {self.type} {self.name} invalid: \'{record}\': {detail}'
  122. records_canonical_format = set()
  123. for r in records_presentation_format:
  124. try:
  125. r_canonical_format = RR.canonical_presentation_format(r, self.type)
  126. except ValueError as ex:
  127. errors.append(_error_msg(r, str(ex)))
  128. else:
  129. if r_canonical_format in records_canonical_format:
  130. errors.append(_error_msg(r, f'Duplicate record content: this is identical to '
  131. f'\'{r_canonical_format}\''))
  132. else:
  133. records_canonical_format.add(r_canonical_format)
  134. if any(errors):
  135. raise ValidationError(errors)
  136. return records_canonical_format
  137. def save_records(self, records):
  138. """
  139. Updates this RR set's resource records, discarding any old values.
  140. Records are expected in presentation format and are converted to canonical
  141. presentation format (e.g., 127.00.0.1 will be converted to 127.0.0.1).
  142. Raises if a invalid set of records is provided.
  143. This method triggers the following database queries:
  144. - one DELETE query
  145. - one SELECT query for comparison of old with new records
  146. - one INSERT query, if one or more records were added
  147. Changes are saved to the database immediately.
  148. :param records: list of records in presentation format
  149. """
  150. new_records = self.clean_records(records)
  151. # Delete RRs that are not in the new record list from the DB
  152. self.records.exclude(content__in=new_records).delete() # one DELETE
  153. # Retrieve all remaining RRs from the DB
  154. unchanged_records = set(r.content for r in self.records.all()) # one SELECT
  155. # Save missing RRs from the new record list to the DB
  156. added_records = new_records - unchanged_records
  157. rrs = [RR(rrset=self, content=content) for content in added_records]
  158. RR.objects.bulk_create(rrs) # One INSERT
  159. def __str__(self):
  160. return '<RRSet %s domain=%s type=%s subname=%s>' % (self.pk, self.domain.name, self.type, self.subname)
  161. class RRManager(Manager):
  162. def bulk_create(self, rrs, **kwargs):
  163. ret = super().bulk_create(rrs, **kwargs)
  164. # For each rrset, save once to set RRset.updated timestamp and trigger signal for post-save processing
  165. rrsets = {rr.rrset for rr in rrs}
  166. for rrset in rrsets:
  167. rrset.save()
  168. return ret
  169. class RR(ExportModelOperationsMixin('RR'), models.Model):
  170. created = models.DateTimeField(auto_now_add=True)
  171. rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name='records')
  172. content = models.TextField()
  173. objects = RRManager()
  174. _type_map = {
  175. dns.rdatatype.AAAA: AAAA, # TODO remove when https://github.com/PowerDNS/pdns/issues/8182 is fixed
  176. dns.rdatatype.CERT: CERT, # do DNS name validation the same way as pdns
  177. dns.rdatatype.MX: MX, # do DNS name validation the same way as pdns
  178. dns.rdatatype.NS: NS, # do DNS name validation the same way as pdns
  179. dns.rdatatype.SRV: SRV, # do DNS name validation the same way as pdns
  180. dns.rdatatype.TXT: LongQuotedTXT, # we slightly deviate from RFC 1035 and allow tokens longer than 255 bytes
  181. dns.rdatatype.SPF: LongQuotedTXT, # we slightly deviate from RFC 1035 and allow tokens longer than 255 bytes
  182. }
  183. @staticmethod
  184. def canonical_presentation_format(any_presentation_format, type_):
  185. """
  186. Converts any valid presentation format for a RR into it's canonical presentation format.
  187. Raises if provided presentation format is invalid.
  188. """
  189. rdtype = rdatatype.from_text(type_)
  190. try:
  191. # Convert to wire format, ensuring input validation.
  192. cls = RR._type_map.get(rdtype, dns.rdata)
  193. wire = cls.from_text(
  194. rdclass=rdataclass.IN,
  195. rdtype=rdtype,
  196. tok=dns.tokenizer.Tokenizer(any_presentation_format),
  197. relativize=False
  198. ).to_digestable()
  199. if len(wire) > 64000:
  200. raise ValidationError(f'Ensure this value has no more than 64000 byte in wire format (it has {len(wire)}).')
  201. parser = dns.wire.Parser(wire, current=0)
  202. with parser.restrict_to(len(wire)):
  203. rdata = cls.from_wire_parser(rdclass=rdataclass.IN, rdtype=rdtype, parser=parser)
  204. # Convert to canonical presentation format, disable chunking of records.
  205. # Exempt types which have chunksize hardcoded (prevents "got multiple values for keyword argument 'chunksize'").
  206. chunksize_exception_types = (dns.rdatatype.OPENPGPKEY, dns.rdatatype.EUI48, dns.rdatatype.EUI64)
  207. if rdtype in chunksize_exception_types:
  208. return rdata.to_text()
  209. else:
  210. return rdata.to_text(chunksize=0)
  211. except binascii.Error:
  212. # e.g., odd-length string
  213. raise ValueError('Cannot parse hexadecimal or base64 record contents')
  214. except dns.exception.SyntaxError as e:
  215. # e.g., A/127.0.0.999
  216. if 'quote' in e.args[0]:
  217. raise ValueError(f'Data for {type_} records must be given using quotation marks.')
  218. else:
  219. raise ValueError(f'Record content for type {type_} malformed: {",".join(e.args)}')
  220. except dns.name.NeedAbsoluteNameOrOrigin:
  221. raise ValueError('Hostname must be fully qualified (i.e., end in a dot: "example.com.")')
  222. except ValueError as ex:
  223. # e.g., string ("asdf") cannot be parsed into int on base 10
  224. raise ValueError(f'Cannot parse record contents: {ex}')
  225. except Exception as e:
  226. # TODO see what exceptions raise here for faulty input
  227. raise e
  228. def __str__(self):
  229. return '<RR %s %s rr_set=%s>' % (self.pk, self.content, self.rrset.pk)