records.py 12 KB

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