diff --git a/docs/developer/README.md b/docs/developer/README.md index 0fa9d9a8c..46194bd70 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -378,3 +378,18 @@ Then, copy the variables under the section labled `s3`. ## Request Flow FSM Diagram The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects. + + +## Testing the prototype add DNS record feature (delete this after we are done testing!) +We are currently testing using cloudflare to add DNS records. Specifically, an A record. To use this, you will need to enable the +`prototype_dns_flag` waffle flag and navigate to `igorville.gov`, `dns.gov`, or `domainops.gov`. Click manage, then click DNS. From there, click the `Prototype DNS record creator` button. + +Before we can send data to cloudflare, you will need these values in your .env file: +``` +REGISTRY_TENANT_KEY = {tenant key} +REGISTRY_SERVICE_EMAIL = {An email address} +REGISTRY_TENANT_NAME = {Name of the bucket, i.e. "CISA" } +``` +You can obtain these by following the steps outlined in the [dns hosting discovery doc](https://docs.google.com/document/d/1Yq5d2M3MgM2vPhUBZ0k5wOmCQst4vND9-2qEZ55-h-Y/edit?tab=t.0), BUT it is far easier to just get these from someone else. Reach out to Zander for this information if you do not have it. + +Alternatively, if you are testing on a sandbox, you will need to add those to getgov-credentials. diff --git a/src/docker-compose.yml b/src/docker-compose.yml index f20aa61e4..5ad6d0ce6 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -59,6 +59,9 @@ services: - AWS_S3_BUCKET_NAME # File encryption credentials - SECRET_ENCRYPT_METADATA + - REGISTRY_TENANT_KEY + - REGISTRY_SERVICE_EMAIL + - REGISTRY_TENANT_NAME stdin_open: true tty: true ports: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5ae3457c3..0e8e4847a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3785,6 +3785,14 @@ class WaffleFlagAdmin(FlagAdmin): model = models.WaffleFlag fields = "__all__" + # Hack to get the dns_prototype_flag to auto populate when you navigate to + # the waffle flag page. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + return super().changelist_view(request, extra_context=extra_context) + class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): list_display = ["name", "portfolio"] diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index a18a813f1..93ba60dfe 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -86,6 +86,11 @@ secret_registry_key = b64decode(secret("REGISTRY_KEY", "")) secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "") secret_registry_hostname = secret("REGISTRY_HOSTNAME") +# PROTOTYPE: Used for DNS hosting +secret_registry_tenant_key = secret("REGISTRY_TENANT_KEY", None) +secret_registry_tenant_name = secret("REGISTRY_TENANT_NAME", None) +secret_registry_service_email = secret("REGISTRY_SERVICE_EMAIL", None) + # region: Basic Django Config-----------------------------------------------### # Build paths inside the project like this: BASE_DIR / "subdir". @@ -685,6 +690,9 @@ SECRET_REGISTRY_CERT = secret_registry_cert SECRET_REGISTRY_KEY = secret_registry_key SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase SECRET_REGISTRY_HOSTNAME = secret_registry_hostname +SECRET_REGISTRY_TENANT_KEY = secret_registry_tenant_key +SECRET_REGISTRY_TENANT_NAME = secret_registry_tenant_name +SECRET_REGISTRY_SERVICE_EMAIL = secret_registry_service_email # endregion # region: Security and Privacy----------------------------------------------### diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 277db5b66..caf51cc36 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -293,6 +293,7 @@ urlpatterns = [ name="todo", ), path("domain/", views.DomainView.as_view(), name="domain"), + path("domain//prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"), path("domain//users", views.DomainUsersView.as_view(), name="domain-users"), path( "domain//dns", diff --git a/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py b/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py new file mode 100644 index 000000000..c3af6905e --- /dev/null +++ b/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.10 on 2024-11-27 21:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0138_alter_domaininvitation_status"), + ] + + operations = [ + migrations.AlterField( + model_name="domainrequest", + name="action_needed_reason", + field=models.TextField( + blank=True, + choices=[ + ("eligibility_unclear", "Unclear organization eligibility"), + ("questionable_senior_official", "Questionable senior official"), + ("already_has_a_domain", "Already has a domain"), + ("bad_name", "Doesn’t meet naming requirements"), + ("other", "Other (no auto-email sent)"), + ], + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7fdc56971..cc600e1ce 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -4,7 +4,6 @@ import ipaddress import re from datetime import date from typing import Optional - from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 3fa889a3b..44d8511b0 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -280,7 +280,7 @@ class DomainRequest(TimeStampedModel): ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official") - ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") + ALREADY_HAS_A_DOMAIN = ("already_has_a_domain", "Already has a domain") BAD_NAME = ("bad_name", "Doesn’t meet naming requirements") OTHER = ("other", "Other (no auto-email sent)") diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index 9a2070c64..c8ece2e32 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -28,6 +28,7 @@

The Domain Name System (DNS) is the internet service that translates your domain name into an IP address. Before your .gov domain can be used, you'll need to connect it to a DNS hosting service and provide us with your name server information.

You can enter your name servers, as well as other DNS-related information, in the following sections:

+ {% url 'domain-dns-nameservers' pk=domain.id as url %}
    @@ -35,6 +36,9 @@ {% url 'domain-dns-dnssec' pk=domain.id as url %}
  • DNSSEC
  • + {% if dns_prototype_flag and is_valid_domain %} +
  • Prototype DNS record creator
  • + {% endif %}
{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_a_domain.txt similarity index 100% rename from src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt rename to src/registrar/templates/emails/action_needed_reasons/already_has_a_domain.txt diff --git a/src/registrar/templates/prototype_domain_dns.html b/src/registrar/templates/prototype_domain_dns.html new file mode 100644 index 000000000..656e0685a --- /dev/null +++ b/src/registrar/templates/prototype_domain_dns.html @@ -0,0 +1,34 @@ +{% extends "domain_base.html" %} +{% load static field_helpers url_helpers %} + +{% block title %}Prototype DNS | {{ domain.name }} | {% endblock %} + +{% block domain_content %} + + {% include "includes/form_errors.html" with form=form %} + +

Add DNS records

+ +

+ This is a prototype that demonstrates adding an 'A' record to a zone. + Do note that this just adds records, but does not update or delete existing ones. +

+

+ You can only use this functionality on a limited set of domains: + + igorville.gov, dns.gov (non-prod), and domainops.gov (non-prod). + +

+
+ {% csrf_token %} + {% input_with_errors form.name %} + {% input_with_errors form.content %} + {% input_with_errors form.ttl %} + +
+{% endblock %} {# domain_content #} \ No newline at end of file diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index d2e4c1c1b..df0902719 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -203,7 +203,7 @@ class TestDomainRequestAdmin(MockEppLib): domain_request.save() domain_request.action_needed() - domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS + domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN domain_request.save() # Let's just change the action needed reason @@ -230,7 +230,7 @@ class TestDomainRequestAdmin(MockEppLib): "In review", "Rejected - Purpose requirements not met", "Action needed - Unclear organization eligibility", - "Action needed - Already has domains", + "Action needed - Already has a domain", "In review", "Submitted", "Started", @@ -241,7 +241,7 @@ class TestDomainRequestAdmin(MockEppLib): assert_status_count(normalized_content, "Started", 1) assert_status_count(normalized_content, "Submitted", 1) assert_status_count(normalized_content, "In review", 2) - assert_status_count(normalized_content, "Action needed - Already has domains", 1) + assert_status_count(normalized_content, "Action needed - Already has a domain", 1) assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1) assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1) @@ -685,9 +685,9 @@ class TestDomainRequestAdmin(MockEppLib): # Create a sample domain request domain_request = completed_domain_request(status=in_review, user=_creator) - # Test the email sent out for already_has_domains - already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) + # Test the email sent out for already_has_a_domain + already_has_a_domain = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_a_domain) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 9a9cb7856..a80b16b1a 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -13,6 +13,7 @@ from .domain import ( DomainAddUserView, DomainInvitationCancelView, DomainDeleteUserView, + PrototypeDomainDNSRecordView, ) from .user_profile import UserProfileView, FinishProfileSetupView from .health import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 9bf6f5313..cb3da1f83 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -7,7 +7,7 @@ inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView). from datetime import date import logging - +import requests from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.db import IntegrityError @@ -64,6 +64,7 @@ from epplibwrapper import ( from ..utility.email import send_templated_email, EmailSendingError from .utility import DomainPermissionView, DomainInvitationPermissionCancelView +from django import forms logger = logging.getLogger(__name__) @@ -454,6 +455,216 @@ class DomainDNSView(DomainBaseView): """DNS Information View.""" template_name = "domain_dns.html" + valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] + + def get_context_data(self, **kwargs): + """Adds custom context.""" + context = super().get_context_data(**kwargs) + context["dns_prototype_flag"] = flag_is_active_for_user(self.request.user, "dns_prototype_flag") + context["is_valid_domain"] = self.object.name in self.valid_domains + return context + + +class PrototypeDomainDNSRecordForm(forms.Form): + """Form for adding DNS records in prototype.""" + + name = forms.CharField(label="DNS record name (A record)", required=True, help_text="DNS record name") + + content = forms.GenericIPAddressField( + label="IPv4 Address", + required=True, + protocol="IPv4", + ) + + ttl = forms.ChoiceField( + label="TTL", + choices=[ + (1, "Automatic"), + (60, "1 minute"), + (300, "5 minutes"), + (1800, "30 minutes"), + (3600, "1 hour"), + (7200, "2 hours"), + (18000, "5 hours"), + (43200, "12 hours"), + (86400, "1 day"), + ], + initial=1, + ) + + +class PrototypeDomainDNSRecordView(DomainFormBaseView): + template_name = "prototype_domain_dns.html" + form_class = PrototypeDomainDNSRecordForm + valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] + + def has_permission(self): + has_permission = super().has_permission() + if not has_permission: + return False + + flag_enabled = flag_is_active_for_user(self.request.user, "dns_prototype_flag") + if not flag_enabled: + return False + + self.object = self.get_object() + if self.object.name not in self.valid_domains: + return False + + return True + + def get_success_url(self): + return reverse("prototype-domain-dns", kwargs={"pk": self.object.pk}) + + def find_by_name(self, items, name): + """Find an item by name in a list of dictionaries.""" + return next((item.get("id") for item in items if item.get("name") == name), None) + + def post(self, request, *args, **kwargs): + """Handle form submission.""" + self.object = self.get_object() + form = self.get_form() + errors = [] + if form.is_valid(): + try: + if settings.IS_PRODUCTION and self.object.name != "igorville.gov": + raise Exception(f"create dns record was called for domain {self.name}") + + if not settings.IS_PRODUCTION and self.object.name not in self.valid_domains: + raise Exception( + f"Can only create DNS records for: {self.valid_domains}." + " Create one in a test environment if it doesn't already exist." + ) + + base_url = "https://api.cloudflare.com/client/v4" + headers = { + "X-Auth-Email": settings.SECRET_REGISTRY_SERVICE_EMAIL, + "X-Auth-Key": settings.SECRET_REGISTRY_TENANT_KEY, + "Content-Type": "application/json", + } + params = {"tenant_name": settings.SECRET_REGISTRY_TENANT_NAME} + + # 1. Get tenant details + tenant_response = requests.get(f"{base_url}/user/tenants", headers=headers, params=params, timeout=5) + tenant_response_json = tenant_response.json() + logger.info(f"Found tenant: {tenant_response_json}") + tenant_id = tenant_response_json["result"][0]["tenant_tag"] + errors = tenant_response_json.get("errors", []) + tenant_response.raise_for_status() + + # 2. Create or get a account under tenant + + # Check to see if the account already exists. Filters accounts by tenant_id / account_name. + account_name = f"account-{self.object.name}" + params = {"tenant_id": tenant_id, "name": account_name} + + account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params, timeout=5) + account_response_json = account_response.json() + logger.debug(f"account get: {account_response_json}") + errors = account_response_json.get("errors", []) + account_response.raise_for_status() + + # See if we already made an account. + # This maybe doesn't need to be a for loop (1 record or 0) but alas, here we are + accounts = account_response_json.get("result", []) + account_id = self.find_by_name(accounts, account_name) + + # If we didn't, create one + if not account_id: + account_response = requests.post( + f"{base_url}/accounts", + headers=headers, + json={"name": account_name, "type": "enterprise", "unit": {"id": tenant_id}}, + timeout=5, + ) + account_response_json = account_response.json() + logger.info(f"Created account: {account_response_json}") + account_id = account_response_json["result"]["id"] + errors = account_response_json.get("errors", []) + account_response.raise_for_status() + + # 3. Create or get a zone under account + + # Try to find an existing zone first by searching on the current id + zone_name = self.object.name + params = {"account.id": account_id, "name": zone_name} + zone_response = requests.get(f"{base_url}/zones", headers=headers, params=params, timeout=5) + zone_response_json = zone_response.json() + logger.debug(f"get zone: {zone_response_json}") + errors = zone_response_json.get("errors", []) + zone_response.raise_for_status() + + # Get the zone id + zones = zone_response_json.get("result", []) + zone_id = self.find_by_name(zones, zone_name) + + # Create one if it doesn't presently exist + if not zone_id: + zone_response = requests.post( + f"{base_url}/zones", + headers=headers, + json={"name": zone_name, "account": {"id": account_id}, "type": "full"}, + timeout=5, + ) + zone_response_json = zone_response.json() + logger.info(f"Created zone: {zone_response_json}") + zone_id = zone_response_json.get("result", {}).get("id") + errors = zone_response_json.get("errors", []) + zone_response.raise_for_status() + + # 4. Add or get a zone subscription + + # See if one already exists + subscription_response = requests.get( + f"{base_url}/zones/{zone_id}/subscription", headers=headers, timeout=5 + ) + subscription_response_json = subscription_response.json() + logger.debug(f"get subscription: {subscription_response_json}") + + # Create a subscription if one doesn't exist already. + # If it doesn't, we get this error message (code 1207): + # Add a core subscription first and try again. The zone does not have an active core subscription. + # Note that status code and error code are different here. + if subscription_response.status_code == 404: + subscription_response = requests.post( + f"{base_url}/zones/{zone_id}/subscription", + headers=headers, + json={"rate_plan": {"id": "PARTNERS_ENT"}, "frequency": "annual"}, + timeout=5, + ) + subscription_response.raise_for_status() + subscription_response_json = subscription_response.json() + logger.info(f"Created subscription: {subscription_response_json}") + else: + subscription_response.raise_for_status() + + # # 5. Create DNS record + # # Format the DNS record according to Cloudflare's API requirements + dns_response = requests.post( + f"{base_url}/zones/{zone_id}/dns_records", + headers=headers, + json={ + "type": "A", + "name": form.cleaned_data["name"], + "content": form.cleaned_data["content"], + "ttl": int(form.cleaned_data["ttl"]), + "comment": "Test record (will need clean up)", + }, + timeout=5, + ) + dns_response_json = dns_response.json() + logger.info(f"Created DNS record: {dns_response_json}") + errors = dns_response_json.get("errors", []) + dns_response.raise_for_status() + dns_name = dns_response_json["result"]["name"] + messages.success(request, f"DNS A record '{dns_name}' created successfully.") + except Exception as err: + logger.error(f"Error creating DNS A record for {self.object.name}: {err}") + messages.error(request, f"An error occurred: {err}") + finally: + if errors: + messages.error(request, f"Request errors: {errors}") + return super().post(request) class DomainNameserversView(DomainFormBaseView):