domains.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import dns.name
  2. import dns.zone
  3. from rest_framework import serializers
  4. from api import settings
  5. from desecapi.models import Domain, RR_SET_TYPES_AUTOMATIC
  6. from desecapi.validators import ReadOnlyOnUpdateValidator
  7. from .records import RRsetSerializer
  8. class DomainSerializer(serializers.ModelSerializer):
  9. default_error_messages = {
  10. **serializers.Serializer.default_error_messages,
  11. "name_unavailable": "This domain name conflicts with an existing zone, or is disallowed by policy.",
  12. }
  13. zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
  14. class Meta:
  15. model = Domain
  16. fields = (
  17. "created",
  18. "published",
  19. "name",
  20. "keys",
  21. "minimum_ttl",
  22. "touched",
  23. "zonefile",
  24. )
  25. read_only_fields = (
  26. "published",
  27. "minimum_ttl",
  28. )
  29. extra_kwargs = {
  30. "name": {"trim_whitespace": False},
  31. }
  32. def __init__(self, *args, include_keys=False, **kwargs):
  33. self.include_keys = include_keys
  34. self.import_zone = None
  35. super().__init__(*args, **kwargs)
  36. def get_fields(self):
  37. fields = super().get_fields()
  38. if not self.include_keys:
  39. fields.pop("keys")
  40. fields["name"].validators.append(ReadOnlyOnUpdateValidator())
  41. return fields
  42. def validate_name(self, value):
  43. if not Domain(name=value, owner=self.context["request"].user).is_registrable():
  44. raise serializers.ValidationError(
  45. self.default_error_messages["name_unavailable"], code="name_unavailable"
  46. )
  47. return value
  48. def parse_zonefile(self, domain_name: str, zonefile: str):
  49. try:
  50. self.import_zone = dns.zone.from_text(
  51. zonefile,
  52. origin=dns.name.from_text(domain_name),
  53. allow_include=False,
  54. check_origin=False,
  55. relativize=False,
  56. )
  57. except dns.zonefile.CNAMEAndOtherData:
  58. raise serializers.ValidationError(
  59. {
  60. "zonefile": [
  61. "No other records with the same name are allowed alongside a CNAME record."
  62. ]
  63. }
  64. )
  65. except ValueError as e:
  66. if "has non-origin SOA" in str(e):
  67. raise serializers.ValidationError(
  68. {
  69. "zonefile": [
  70. f"Zonefile includes an SOA record for a name different from {domain_name}."
  71. ]
  72. }
  73. )
  74. raise e
  75. except dns.exception.SyntaxError as e:
  76. try:
  77. line = str(e).split(":")[1]
  78. raise serializers.ValidationError(
  79. {"zonefile": [f"Zonefile contains syntax error in line {line}."]}
  80. )
  81. except IndexError:
  82. raise serializers.ValidationError(
  83. {"zonefile": [f"Could not parse zonefile: {str(e)}"]}
  84. )
  85. def validate(self, attrs):
  86. if attrs.get("zonefile") is not None:
  87. self.parse_zonefile(attrs.get("name"), attrs.pop("zonefile"))
  88. return super().validate(attrs)
  89. def create(self, validated_data):
  90. # save domain
  91. if (
  92. "minimum_ttl" not in validated_data
  93. and Domain(name=validated_data["name"]).is_locally_registrable
  94. ):
  95. validated_data.update(minimum_ttl=60)
  96. domain: Domain = super().create(validated_data)
  97. # save RRsets if zonefile was given
  98. nodes = getattr(self.import_zone, "nodes", None)
  99. if nodes:
  100. zone_name = dns.name.from_text(validated_data["name"])
  101. min_ttl, max_ttl = domain.minimum_ttl, settings.MAXIMUM_TTL
  102. data = [
  103. {
  104. "type": dns.rdatatype.to_text(rrset.rdtype),
  105. "ttl": max(min_ttl, min(max_ttl, rrset.ttl)),
  106. "subname": (owner_name - zone_name).to_text()
  107. if owner_name - zone_name != dns.name.empty
  108. else "",
  109. "records": [rr.to_text() for rr in rrset],
  110. }
  111. for owner_name, node in nodes.items()
  112. for rrset in node.rdatasets
  113. if (
  114. dns.rdatatype.to_text(rrset.rdtype)
  115. not in (
  116. RR_SET_TYPES_AUTOMATIC
  117. | { # do not import automatically managed record types
  118. "CDS",
  119. "CDNSKEY",
  120. "DNSKEY",
  121. } # do not import these, as this would likely be unexpected
  122. )
  123. and not (
  124. owner_name - zone_name == dns.name.empty
  125. and rrset.rdtype == dns.rdatatype.NS
  126. ) # ignore apex NS
  127. )
  128. ]
  129. rrset_list_serializer = RRsetSerializer(
  130. data=data, context=dict(domain=domain), many=True
  131. )
  132. # The following line raises if data passed validation by dnspython during zone file parsing,
  133. # but is rejected by validation in RRsetSerializer. See also
  134. # test_create_domain_zonefile_import_validation
  135. try:
  136. rrset_list_serializer.is_valid(raise_exception=True)
  137. except serializers.ValidationError as e:
  138. if isinstance(e.detail, serializers.ReturnList):
  139. # match the order of error messages with the RRsets provided to the
  140. # serializer to make sense to the client
  141. def fqdn(idx):
  142. return (data[idx]["subname"] + "." + domain.name).lstrip(".")
  143. raise serializers.ValidationError(
  144. {
  145. "zonefile": [
  146. f"{fqdn(idx)}/{data[idx]['type']}: {err}"
  147. for idx, d in enumerate(e.detail)
  148. for _, errs in d.items()
  149. for err in errs
  150. ]
  151. }
  152. )
  153. raise e
  154. rrset_list_serializer.save()
  155. return domain