Write a new Domain interface

This commit is contained in:
Seamus Johnston 2023-05-26 14:51:11 -05:00
parent dd58f46231
commit d51a8600d4
No known key found for this signature in database
GPG key ID: 2F21225985069105
6 changed files with 255 additions and 221 deletions

View file

@ -28,8 +28,7 @@ information to make connections between registry users and the domains that
they manage. The registrar stores very few fields about a domain except for they manage. The registrar stores very few fields about a domain except for
its name, so it could be straightforward to import the exported list of domains its name, so it could be straightforward to import the exported list of domains
from Verisign's `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that from Verisign's `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that
that table stores a flag for active or inactive, so every domain in the file that table stores a flag for active or inactive.
can be imported into our system as `is_active=True`.
An example Django management command that can load the delimited text file An example Django management command that can load the delimited text file
from the daily escrow is in from the daily escrow is in

View file

@ -0,0 +1,45 @@
# Generated by Django 4.2.1 on 2023-05-26 19:21
from django.db import migrations, models
import registrar.models.utility.domain_field
class Migration(migrations.Migration):
dependencies = [
("registrar", "0022_draftdomain_domainapplication_approved_domain_and_more"),
]
operations = [
migrations.RemoveConstraint(
model_name="domain",
name="unique_domain_name_in_registry",
),
migrations.RemoveField(
model_name="domain",
name="is_active",
),
migrations.AddField(
model_name="domain",
name="state",
field=models.CharField(
choices=[
("created", "Created"),
("deleted", "Deleted"),
("unknown", "Unknown"),
],
default="unknown",
help_text="Very basic info about the lifecycle of this domain object",
max_length=21,
),
),
migrations.AlterField(
model_name="domain",
name="name",
field=registrar.models.utility.domain_field.DomainField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
unique=True,
),
),
]

View file

@ -1,42 +1,44 @@
import logging import logging
import re
from typing import List from datetime import date
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django_fsm import FSMField, transition # type: ignore
from api.views import in_domains from epplibwrapper import (
from registrar.utility import errors CLIENT as registry,
commands,
)
from .utility.domain_field import DomainField
from .utility.domain_helper import DomainHelper
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Domain(TimeStampedModel): class Domain(TimeStampedModel, DomainHelper):
""" """
Manage the lifecycle of domain names. Manage the lifecycle of domain names.
The registry is the source of truth for this data and this model exists: The registry is the source of truth for this data and this model exists:
1. To tie ownership information in the registrar to 1. To tie ownership information in the registrar to
DNS entries in the registry; and DNS entries in the registry
2. To allow a new registrant to draft DNS entries before their
application is approved
"""
class Meta: ~~~ HOW TO USE THIS CLASS ~~~
constraints = [
# draft domains may share the same name, but A) You can create a Domain object with just a name. `Domain(name="something.gov")`.
# once approved, they must be globally unique B) Saving the Domain object will not contact the registry, as it may be useful
models.UniqueConstraint( to have Domain objects in an `UNKNOWN` pre-created state.
fields=["name"], C) Domain properties are lazily loaded. Accessing `my_domain.expiration_date` will
condition=models.Q(is_active=True), contact the registry, if a cached copy does not exist.
name="unique_domain_name_in_registry", D) Domain creation is lazy. If `my_domain.expiration_date` finds that `my_domain`
), does not exist in the registry, it will ask the registry to create it.
] F) Created is _not_ the same as active aka live on the internet.
G) Activation is controlled by the registry. It will happen automatically when the
domain meets the required checks.
"""
class Status(models.TextChoices): class Status(models.TextChoices):
""" """
@ -91,221 +93,193 @@ class Domain(TimeStampedModel):
PENDING_TRANSFER = "pendingTransfer" PENDING_TRANSFER = "pendingTransfer"
PENDING_UPDATE = "pendingUpdate" PENDING_UPDATE = "pendingUpdate"
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't class State(models.TextChoices):
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters """These capture (some of) the states a domain object can be in."""
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
@classmethod # the normal state of a domain object -- may or may not be active!
def string_could_be_domain(cls, domain: str | None) -> bool: CREATED = "created"
"""Return True if the string could be a domain name, otherwise False."""
if not isinstance(domain, str):
return False
return bool(cls.DOMAIN_REGEX.match(domain))
@classmethod # previously existed but has been deleted from the registry
def validate(cls, domain: str | None, blank_ok=False) -> str: DELETED = "deleted"
"""Attempt to determine if a domain name could be requested."""
if domain is None: # the state is indeterminate
raise errors.BlankValueError() UNKNOWN = "unknown"
if not isinstance(domain, str):
raise ValueError("Domain name must be a string")
domain = domain.lower().strip()
if domain == "":
if blank_ok:
return domain
else:
raise errors.BlankValueError()
if domain.endswith(".gov"):
domain = domain[:-4]
if "." in domain:
raise errors.ExtraDotsError()
if not Domain.string_could_be_domain(domain + ".gov"):
raise ValueError()
if in_domains(domain):
raise errors.DomainUnavailableError()
return domain
@classmethod @classmethod
def available(cls, domain: str) -> bool: def available(cls, domain: str) -> bool:
"""Check if a domain is available. """Check if a domain is available."""
if not cls.string_could_be_domain(domain):
raise ValueError("Not a valid domain: %s" % str(domain))
req = commands.CheckDomain([domain])
return registry.send(req).res_data[0].avail
Not implemented. Returns a dummy value for testing.""" @classmethod
return False # domain_check(domain) def registered(cls, domain: str) -> bool:
"""Check if a domain is _not_ available."""
return not cls.available(domain)
@property
def contacts(self) -> dict[str, str]:
"""
Get a dictionary of registry IDs for the contacts for this domain.
IDs are provided as strings, e.g.
{"registrant": "jd1234", "admin": "sh8013",...}
"""
raise NotImplementedError()
@property
def creation_date(self) -> date:
"""Get the `cr_date` element from the registry."""
raise NotImplementedError()
@property
def last_transferred_date(self) -> date:
"""Get the `tr_date` element from the registry."""
raise NotImplementedError()
@property
def last_updated_date(self) -> date:
"""Get the `up_date` element from the registry."""
raise NotImplementedError()
@property
def expiration_date(self) -> date:
"""Get or set the `ex_date` element from the registry."""
raise NotImplementedError()
@expiration_date.setter # type: ignore
def expiration_date(self, ex_date: date):
raise NotImplementedError()
@property
def password(self) -> str:
"""Get the `auth_info.pw` element from the registry. Not a real password."""
raise NotImplementedError()
@property
def nameservers(self) -> list[tuple[str]]:
"""
Get or set a complete list of nameservers for this domain.
Hosts are provided as a list of tuples, e.g.
[("ns1.example.com",), ("ns1.example.gov", "0.0.0.0")]
Subordinate hosts (something.your-domain.gov) MUST have IP addresses,
while non-subordinate hosts MUST NOT.
"""
# TODO: call EPP to get this info instead of returning fake data.
return [
("ns1.example.com",),
("ns2.example.com",),
("ns3.example.com",),
]
@nameservers.setter # type: ignore
def nameservers(self, hosts: list[tuple[str]]):
# TODO: call EPP to set this info.
pass
@property
def statuses(self) -> list[str]:
"""
Get or set the domain `status` elements from the registry.
A domain's status indicates various properties. See Domain.Status.
"""
# implementation note: the Status object from EPP stores the string in
# a dataclass property `state`, not to be confused with the `state` field here
raise NotImplementedError()
@statuses.setter # type: ignore
def statuses(self, statuses: list[str]):
# TODO: there are a long list of rules in the RFC about which statuses
# can be combined; check that here and raise errors for invalid combinations -
# some statuses cannot be set by the client at all
raise NotImplementedError()
@property
def registrant_contact(self) -> PublicContact:
"""Get or set the registrant for this domain."""
raise NotImplementedError()
@registrant_contact.setter # type: ignore
def registrant_contact(self, contact: PublicContact):
raise NotImplementedError()
@property
def administrative_contact(self) -> PublicContact:
"""Get or set the admin contact for this domain."""
raise NotImplementedError()
@administrative_contact.setter # type: ignore
def administrative_contact(self, contact: PublicContact):
raise NotImplementedError()
@property
def security_contact(self) -> PublicContact:
"""Get or set the security contact for this domain."""
# TODO: replace this with a real implementation
contact = PublicContact.get_default_security()
contact.domain = self
contact.email = "mayor@igorville.gov"
return contact
@security_contact.setter # type: ignore
def security_contact(self, contact: PublicContact):
# TODO: replace this with a real implementation
pass
@property
def technical_contact(self) -> PublicContact:
"""Get or set the tech contact for this domain."""
raise NotImplementedError()
@technical_contact.setter # type: ignore
def technical_contact(self, contact: PublicContact):
raise NotImplementedError()
def is_active(self) -> bool:
"""Is the domain live on the inter webs?"""
# TODO: implement a check -- should be performant so it can be called for
# any number of domains on a status page
# this is NOT as simple as checking if Domain.Status.OK is in self.statuses
return False
def transfer(self): def transfer(self):
"""Going somewhere. Not implemented.""" """Going somewhere. Not implemented."""
pass raise NotImplementedError()
def renew(self): def renew(self):
"""Time to renew. Not implemented.""" """Time to renew. Not implemented."""
pass raise NotImplementedError()
def _get_property(self, property): def place_client_hold(self):
"""Get some info about a domain.""" """This domain should not be active."""
if not self.is_active: raise NotImplementedError()
return None
if not hasattr(self, "info"):
try:
# get info from registry
self.info = {} # domain_info(self.name)
except Exception as e:
logger.error(e)
# TODO: back off error handling
return None
if hasattr(self, "info"):
if property in self.info:
return self.info[property]
else:
raise KeyError(
"Requested key %s was not found in registry data." % str(property)
)
else:
# TODO: return an error if registry cannot be contacted
return None
@transition(field="is_active", source="*", target=True) def remove_client_hold(self):
def activate(self): """This domain is okay to be active."""
"""This domain should be made live.""" raise NotImplementedError()
DomainApplication = apps.get_model("registrar.DomainApplication")
if hasattr(self, "domain_application"):
if self.domain_application.status != DomainApplication.APPROVED:
raise ValueError("Cannot activate. Application must be approved.")
if Domain.objects.filter(name=self.name, is_active=True).exists():
raise ValueError("Cannot activate. Domain name is already in use.")
# TODO: depending on the details of our registry integration
# we will either contact the registry and deploy the domain
# in this function OR we will verify that it has already been
# activated and reject this state transition if it has not
pass
@transition(field="is_active", source="*", target=False)
def deactivate(self):
"""This domain should not be live."""
# there are security concerns to having this function exist
# within the codebase; discuss these with the project lead
# if there is a feature request to implement this
raise Exception("Cannot revoke, contact registry.")
@property
def sld(self):
"""Get or set the second level domain string."""
return self.name.split(".")[0]
@sld.setter
def sld(self, value: str):
parts = self.name.split(".")
tld = parts[1] if len(parts) > 1 else ""
if Domain.string_could_be_domain(f"{value}.{tld}"):
self.name = f"{value}.{tld}"
else:
raise ValidationError("%s is not a valid second level domain" % value)
@property
def tld(self):
"""Get or set the top level domain string."""
parts = self.name.split(".")
return parts[1] if len(parts) > 1 else ""
@tld.setter
def tld(self, value: str):
sld = self.name.split(".")[0]
if Domain.string_could_be_domain(f"{sld}.{value}"):
self.name = f"{sld}.{value}"
else:
raise ValidationError("%s is not a valid top level domain" % value)
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def nameservers(self) -> List[str]: name = DomainField(
"""A list of the nameservers for this domain.
TODO: call EPP to get this info instead of returning fake data.
"""
return [
# reserved example domain
"ns1.example.com",
"ns2.example.com",
"ns3.example.com",
]
def set_nameservers(self, new_nameservers: List[str]):
"""Set the nameservers for this domain."""
# TODO: call EPP to set these values in the registry instead of doing
# nothing.
logger.warn("TODO: Fake setting nameservers to %s", new_nameservers)
def security_email(self) -> str:
"""Get the security email for this domain.
TODO: call EPP to get this info instead of returning fake data.
"""
return "mayor@igorville.gov"
def set_security_email(self, new_security_email: str):
"""Set the security email for this domain."""
# TODO: call EPP to set these values in the registry instead of doing
# nothing.
logger.warn("TODO: Fake setting security email to %s", new_security_email)
@property
def roid(self):
return self._get_property("roid")
@property
def status(self):
return self._get_property("status")
@property
def registrant(self):
return self._get_property("registrant")
@property
def sponsor(self):
return self._get_property("sponsor")
@property
def creator(self):
return self._get_property("creator")
@property
def creation_date(self):
return self._get_property("creation_date")
@property
def updator(self):
return self._get_property("updator")
@property
def last_update_date(self):
return self._get_property("last_update_date")
@property
def expiration_date(self):
return self._get_property("expiration_date")
@property
def last_transfer_date(self):
return self._get_property("last_transfer_date")
name = models.CharField(
max_length=253, max_length=253,
blank=False, blank=False,
default=None, # prevent saving without a value default=None, # prevent saving without a value
unique=True,
help_text="Fully qualified domain name", help_text="Fully qualified domain name",
) )
# we use `is_active` rather than `domain_application.status` state = models.CharField(
# because domains may exist without associated applications max_length=21,
is_active = FSMField( choices=State.choices,
choices=[ default=State.UNKNOWN,
(True, "Yes"), help_text="Very basic info about the lifecycle of this domain object",
(False, "No"),
],
default=False,
# TODO: how to edit models in Django admin if protected = True
protected=False,
help_text="Domain is live in the registry",
) )
# ForeignKey on UserDomainRole creates a "permissions" member for # ForeignKey on UserDomainRole creates a "permissions" member for

View file

@ -32,7 +32,7 @@ class PublicContact(TimeStampedModel):
if hasattr(self, "domain"): if hasattr(self, "domain"):
match self.contact_type: match self.contact_type:
case PublicContact.ContactTypeChoices.REGISTRANT: case PublicContact.ContactTypeChoices.REGISTRANT:
self.domain.registrant = self self.domain.registrant_contact = self
case PublicContact.ContactTypeChoices.ADMINISTRATIVE: case PublicContact.ContactTypeChoices.ADMINISTRATIVE:
self.domain.administrative_contact = self self.domain.administrative_contact = self
case PublicContact.ContactTypeChoices.TECHNICAL: case PublicContact.ContactTypeChoices.TECHNICAL:

View file

@ -0,0 +1,13 @@
from django.db import models
class DomainField(models.CharField):
"""Subclass of CharField to enforce domain name specific requirements."""
def to_python(self, value):
"""Convert to lowercase during deserialization and during form `clean`."""
if value is None:
return value
if isinstance(value, str):
return value.lower()
return str(value).lower()

View file

@ -41,7 +41,7 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
def get_initial(self): def get_initial(self):
"""The initial value for the form (which is a formset here).""" """The initial value for the form (which is a formset here)."""
domain = self.get_object() domain = self.get_object()
return [{"server": server} for server in domain.nameservers()] return [{"server": name} for name, *ip in domain.nameservers]
def get_success_url(self): def get_success_url(self):
"""Redirect to the overview page for the domain.""" """Redirect to the overview page for the domain."""
@ -82,12 +82,13 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
nameservers = [] nameservers = []
for form in formset: for form in formset:
try: try:
nameservers.append(form.cleaned_data["server"]) as_tuple = (form.cleaned_data["server"],)
nameservers.append(as_tuple)
except KeyError: except KeyError:
# no server information in this field, skip it # no server information in this field, skip it
pass pass
domain = self.get_object() domain = self.get_object()
domain.set_nameservers(nameservers) domain.nameservers = nameservers
messages.success( messages.success(
self.request, "The name servers for this domain have been updated." self.request, "The name servers for this domain have been updated."
@ -109,7 +110,7 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
"""The initial value for the form.""" """The initial value for the form."""
domain = self.get_object() domain = self.get_object()
initial = super().get_initial() initial = super().get_initial()
initial["security_email"] = domain.security_email() initial["security_email"] = domain.security_contact.email
return initial return initial
def get_success_url(self): def get_success_url(self):
@ -132,7 +133,9 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
# Set the security email from the form # Set the security email from the form
new_email = form.cleaned_data.get("security_email", "") new_email = form.cleaned_data.get("security_email", "")
domain = self.get_object() domain = self.get_object()
domain.set_security_email(new_email) contact = domain.security_contact
contact.email = new_email
contact.save()
messages.success( messages.success(
self.request, "The security email for this domain have been updated." self.request, "The security email for this domain have been updated."