From efc4e71625567cc65927215491a95ed8f2460586 Mon Sep 17 00:00:00 2001 From: lizpearl Date: Wed, 27 Nov 2024 14:56:16 -0600 Subject: [PATCH 01/17] Change action needed reason from 'Already has domains' to 'Already has a domain' --- src/registrar/models/domain_request.py | 2 +- ...eady_has_domains.txt => already_has_a_domain.txt} | 0 src/registrar/tests/test_admin_request.py | 12 ++++++------ 3 files changed, 7 insertions(+), 7 deletions(-) rename src/registrar/templates/emails/action_needed_reasons/{already_has_domains.txt => already_has_a_domain.txt} (100%) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 0d8bbd5cf..b132ad5ac 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/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/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 9244fffcd..35912bed6 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) From e5379d950040c5a20f30ae6fe79d1653ecf01bf7 Mon Sep 17 00:00:00 2001 From: lizpearl Date: Wed, 27 Nov 2024 15:25:53 -0600 Subject: [PATCH 02/17] Commit migration after field was renamed --- ...lter_domainrequest_action_needed_reason.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py 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, + ), + ), + ] From 366ecb97d94e93bd2af53e82bbe5cd2ca50b1581 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:07:15 -0700 Subject: [PATCH 03/17] basic logic --- src/registrar/config/settings.py | 6 +++ src/registrar/models/domain.py | 87 +++++++++++++++++++++++++++++++- src/registrar/views/domain.py | 6 +++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index a18a813f1..bcf4d79d6 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -86,6 +86,10 @@ 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_id = secret("REGISTRY_TENANT_ID", None) + # region: Basic Django Config-----------------------------------------------### # Build paths inside the project like this: BASE_DIR / "subdir". @@ -685,6 +689,8 @@ 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_ID = secret_registry_tenant_id # endregion # region: Security and Privacy----------------------------------------------### diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7fdc56971..2718a225e 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1,10 +1,11 @@ from itertools import zip_longest import logging import ipaddress +import requests import re from datetime import date from typing import Optional - +from django.conf import settings from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models @@ -307,6 +308,90 @@ class Domain(TimeStampedModel, DomainHelper): To update the expiration date, use renew_domain method.""" raise NotImplementedError() + def create_dns_record(self, dns_record_dict): + print(f"what is the key? {settings.SECRET_REGISTRY_TENANT_KEY}") + # Cloudflare API endpoints + base_url = "https://api.cloudflare.com/client/v4" + headers = { + "Authorization": f"Bearer {settings.SECRET_REGISTRY_TENANT_KEY}", + "Content-Type": "application/json" + } + if settings.IS_PRODUCTION: + if self.name == "igorville.gov": + # do stuff + pass + else: + logger.warning(f"create_dns_record was called for domain {self.name}") + else: + pass + + # TODO - check if these things exist before doing stuff + # 1. Get tenant details + # Note: we can grab this more generally but lets be specific to keep things safe. + tenant_id = settings.SECRET_REGISTRY_TENANT_ID + account_name = f"account-{self.name}" + + # 2. Create account under tenant + account_response = requests.post( + f"{base_url}/accounts", + headers=headers, + json={ + "name": account_name, + "type": "enterprise", + "unit": {"id": tenant_id} + } + ) + account_response.raise_for_status() + account_response_json = account_response.json() + account_id = account_response_json["result"]["id"] + logger.info(f"Created account: {account_response_json}") + + # 3. Create zone under account + zone_response = requests.post( + f"{base_url}/zones", + headers=headers, + json={ + "name": self.name, + "account": {"id": account_id}, + "type": "full" + } + ) + zone_response.raise_for_status() + zone_response_json = zone_response.json() + zone_id = zone_response_json["result"]["id"] + logger.info(f"Created zone: {zone_id}") + + # 4. Add zone subscription + subscription_response = requests.post( + f"{base_url}/zones/{zone_id}/subscription", + headers=headers, + json={ + "rate_plan": {"id": "PARTNERS_ENT"}, + "frequency": "annual" + } + ) + subscription_response.raise_for_status() + subscription_response_json = subscription_response.json() + logger.info(f"Created subscription: {subscription_response_json}") + + # 5. Create DNS record + dns_response = requests.post( + f"{base_url}/zones/{zone_id}/dns_records", + headers=headers, + json=dns_record_dict + ) + dns_response.raise_for_status() + dns_response_json = dns_response.json() + logger.info(f"Created DNS record: {dns_response_json}") + + return { + "tenant_id": tenant_id, + "account_id": account_id, + "zone_id": zone_id, + "dns_record_id": dns_response_json["result"]["id"] + } + + def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.YEAR): """ Renew the domain to a length and unit of time relative to the current diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 9bf6f5313..b65cd93be 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -455,6 +455,12 @@ class DomainDNSView(DomainBaseView): template_name = "domain_dns.html" + 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") + return context + class DomainNameserversView(DomainFormBaseView): """Domain nameserver editing view.""" From bd64a04a91991945ba4ab6fb290ddaf21f09b6f7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:04:08 -0700 Subject: [PATCH 04/17] split things up --- src/registrar/models/domain.py | 89 +++++++++++++++++++--------------- src/registrar/views/domain.py | 45 +++++++++++++++++ 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 2718a225e..df6f6ac66 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -308,7 +308,52 @@ class Domain(TimeStampedModel, DomainHelper): To update the expiration date, use renew_domain method.""" raise NotImplementedError() - def create_dns_record(self, dns_record_dict): + def create_prototype_account(self, base_url, headers, tenant_id): + account_response = requests.post( + f"{base_url}/accounts", + headers=headers, + json={ + "name": f"account-{self.name}", + "type": "enterprise", + "unit": {"id": tenant_id} + } + ) + account_response.raise_for_status() + account_response_json = account_response.json() + account_id = account_response_json["result"]["id"] + logger.info(f"Created account: {account_response_json}") + return account_id + + def create_prototype_zone(self, base_url, headers, account_id): + zone_response = requests.post( + f"{base_url}/zones", + headers=headers, + json={ + "name": self.name, + "account": {"id": account_id}, + "type": "full" + } + ) + zone_response.raise_for_status() + zone_response_json = zone_response.json() + zone_id = zone_response_json["result"]["id"] + logger.info(f"Created zone: {zone_response_json}") + return zone_id + + def create_prototype_subscription(self, base_url, headers, zone_id): + subscription_response = requests.post( + f"{base_url}/zones/{zone_id}/subscription", + headers=headers, + json={ + "rate_plan": {"id": "PARTNERS_ENT"}, + "frequency": "annual" + } + ) + subscription_response.raise_for_status() + subscription_response_json = subscription_response.json() + logger.info(f"Created subscription: {subscription_response_json}") + + def create_prototype_dns_record(self, dns_record_dict): print(f"what is the key? {settings.SECRET_REGISTRY_TENANT_KEY}") # Cloudflare API endpoints base_url = "https://api.cloudflare.com/client/v4" @@ -329,50 +374,15 @@ class Domain(TimeStampedModel, DomainHelper): # 1. Get tenant details # Note: we can grab this more generally but lets be specific to keep things safe. tenant_id = settings.SECRET_REGISTRY_TENANT_ID - account_name = f"account-{self.name}" # 2. Create account under tenant - account_response = requests.post( - f"{base_url}/accounts", - headers=headers, - json={ - "name": account_name, - "type": "enterprise", - "unit": {"id": tenant_id} - } - ) - account_response.raise_for_status() - account_response_json = account_response.json() - account_id = account_response_json["result"]["id"] - logger.info(f"Created account: {account_response_json}") + account_id = self.create_prototype_account(base_url, headers, tenant_id) # 3. Create zone under account - zone_response = requests.post( - f"{base_url}/zones", - headers=headers, - json={ - "name": self.name, - "account": {"id": account_id}, - "type": "full" - } - ) - zone_response.raise_for_status() - zone_response_json = zone_response.json() - zone_id = zone_response_json["result"]["id"] - logger.info(f"Created zone: {zone_id}") + zone_id = self.create_prototype_zone(base_url, headers, account_id) # 4. Add zone subscription - subscription_response = requests.post( - f"{base_url}/zones/{zone_id}/subscription", - headers=headers, - json={ - "rate_plan": {"id": "PARTNERS_ENT"}, - "frequency": "annual" - } - ) - subscription_response.raise_for_status() - subscription_response_json = subscription_response.json() - logger.info(f"Created subscription: {subscription_response_json}") + self.create_prototype_subscription(base_url, headers, zone_id) # 5. Create DNS record dns_response = requests.post( @@ -391,7 +401,6 @@ class Domain(TimeStampedModel, DomainHelper): "dns_record_id": dns_response_json["result"]["id"] } - def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.YEAR): """ Renew the domain to a length and unit of time relative to the current diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b65cd93be..c7b525770 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -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__) @@ -462,6 +463,50 @@ class DomainDNSView(DomainBaseView): return context +class PrototypeDomainDNSRecordForm(forms.Form): + """Form for adding DNS records in prototype.""" + + record_type = forms.ChoiceField( + label="Record Type", + choices=[ + ("A", "A"), + ("AAAA", "AAAA"), + ("CNAME", "CNAME"), + ("TXT", "TXT") + ], + required=True + ) + + name = forms.CharField( + label="Name", + required=True, + help_text="The DNS record name (e.g., www)" + ) + + content = forms.GenericIPAddressField( + label="IPv4 Address", + required=True, + protocol="IPv4", + help_text="The IPv4 address this record points to" + ) + + 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, + help_text="Time to Live - how long DNS resolvers should cache this record" + ) + class DomainNameserversView(DomainFormBaseView): """Domain nameserver editing view.""" From 24feb0032723ba786b2647584692aae792639bd9 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:09:46 -0700 Subject: [PATCH 05/17] prototype page --- src/registrar/config/urls.py | 1 + src/registrar/models/domain.py | 14 ++-- src/registrar/templates/domain_dns.html | 4 + .../templates/prototype_domain_dns.html | 29 ++++++++ src/registrar/views/__init__.py | 1 + src/registrar/views/domain.py | 73 +++++++++++++++---- 6 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 src/registrar/templates/prototype_domain_dns.html diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 53b83e564..7f9ce3a22 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -298,6 +298,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/models/domain.py b/src/registrar/models/domain.py index df6f6ac66..04d076caf 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -355,20 +355,18 @@ class Domain(TimeStampedModel, DomainHelper): def create_prototype_dns_record(self, dns_record_dict): print(f"what is the key? {settings.SECRET_REGISTRY_TENANT_KEY}") + + # Don't execute this function on any other domain + if settings.IS_PRODUCTION and self.name != "igorville.gov": + logger.warning(f"create_dns_record was called for domain {self.name}") + return None + # Cloudflare API endpoints base_url = "https://api.cloudflare.com/client/v4" headers = { "Authorization": f"Bearer {settings.SECRET_REGISTRY_TENANT_KEY}", "Content-Type": "application/json" } - if settings.IS_PRODUCTION: - if self.name == "igorville.gov": - # do stuff - pass - else: - logger.warning(f"create_dns_record was called for domain {self.name}") - else: - pass # TODO - check if these things exist before doing stuff # 1. Get tenant details diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index 9a2070c64..cf6d2ef7c 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 %} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/prototype_domain_dns.html b/src/registrar/templates/prototype_domain_dns.html new file mode 100644 index 000000000..e588922cf --- /dev/null +++ b/src/registrar/templates/prototype_domain_dns.html @@ -0,0 +1,29 @@ +{% 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 a cloudflare DNS record

+ +

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

+ +
+ {% 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/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 c7b525770..85f66b07b 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -465,29 +465,17 @@ class DomainDNSView(DomainBaseView): class PrototypeDomainDNSRecordForm(forms.Form): """Form for adding DNS records in prototype.""" - - record_type = forms.ChoiceField( - label="Record Type", - choices=[ - ("A", "A"), - ("AAAA", "AAAA"), - ("CNAME", "CNAME"), - ("TXT", "TXT") - ], - required=True - ) - + name = forms.CharField( - label="Name", + label="DNS record name (A record)", required=True, - help_text="The DNS record name (e.g., www)" + help_text="DNS record name" ) content = forms.GenericIPAddressField( label="IPv4 Address", required=True, protocol="IPv4", - help_text="The IPv4 address this record points to" ) ttl = forms.ChoiceField( @@ -504,9 +492,62 @@ class PrototypeDomainDNSRecordForm(forms.Form): (86400, "1 day") ], initial=1, - help_text="Time to Live - how long DNS resolvers should cache this record" ) +class PrototypeDomainDNSRecordView(DomainFormBaseView): + template_name = "prototype_domain_dns.html" + form_class = PrototypeDomainDNSRecordForm + 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 + + return True + + def get_success_url(self): + return reverse("prototype-domain-dns", kwargs={"pk": self.object.pk}) + + def post(self, request, *args, **kwargs): + """Handle form submission.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + try: + # Format the DNS record according to Cloudflare's API requirements + dns_record = { + "type": "A", + "name": form.cleaned_data["name"], + "content": form.cleaned_data["content"], + "ttl": int(form.cleaned_data["ttl"]), + "comment": f"Test record (will eventually need to clean up)" + } + + result = self.object.create_prototype_dns_record(dns_record) + + if result: # Assuming create_prototype_dns_record returns the response data + messages.success( + request, + f"DNS A record '{form.cleaned_data['name']}' created successfully." + ) + else: + messages.error( + request, + "Failed to create DNS A record. Please try again." + ) + + except Exception as err: + logger.error(f"Error creating DNS A record for {self.object.name}: {err}") + messages.error( + request, + f"An error occurred while creating the DNS A record: {err}" + ) + return super().post(request) + + class DomainNameserversView(DomainFormBaseView): """Domain nameserver editing view.""" From 751ce2437814e72901ad855b6dc63e548b7b1140 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:41:57 -0700 Subject: [PATCH 06/17] creating / getting accounts --- src/docker-compose.yml | 3 + src/registrar/config/settings.py | 6 +- src/registrar/models/domain.py | 92 +----------- .../templates/prototype_domain_dns.html | 1 + src/registrar/views/domain.py | 134 +++++++++++++++--- 5 files changed, 125 insertions(+), 111 deletions(-) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 8cb2bd60f..10d246081 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/config/settings.py b/src/registrar/config/settings.py index bcf4d79d6..93ba60dfe 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -88,7 +88,8 @@ secret_registry_hostname = secret("REGISTRY_HOSTNAME") # PROTOTYPE: Used for DNS hosting secret_registry_tenant_key = secret("REGISTRY_TENANT_KEY", None) -secret_registry_tenant_id = secret("REGISTRY_TENANT_ID", None) +secret_registry_tenant_name = secret("REGISTRY_TENANT_NAME", None) +secret_registry_service_email = secret("REGISTRY_SERVICE_EMAIL", None) # region: Basic Django Config-----------------------------------------------### @@ -690,7 +691,8 @@ 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_ID = secret_registry_tenant_id +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/models/domain.py b/src/registrar/models/domain.py index 04d076caf..a96f9f6ec 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -239,6 +239,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" + return True if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in @@ -308,97 +309,6 @@ class Domain(TimeStampedModel, DomainHelper): To update the expiration date, use renew_domain method.""" raise NotImplementedError() - def create_prototype_account(self, base_url, headers, tenant_id): - account_response = requests.post( - f"{base_url}/accounts", - headers=headers, - json={ - "name": f"account-{self.name}", - "type": "enterprise", - "unit": {"id": tenant_id} - } - ) - account_response.raise_for_status() - account_response_json = account_response.json() - account_id = account_response_json["result"]["id"] - logger.info(f"Created account: {account_response_json}") - return account_id - - def create_prototype_zone(self, base_url, headers, account_id): - zone_response = requests.post( - f"{base_url}/zones", - headers=headers, - json={ - "name": self.name, - "account": {"id": account_id}, - "type": "full" - } - ) - zone_response.raise_for_status() - zone_response_json = zone_response.json() - zone_id = zone_response_json["result"]["id"] - logger.info(f"Created zone: {zone_response_json}") - return zone_id - - def create_prototype_subscription(self, base_url, headers, zone_id): - subscription_response = requests.post( - f"{base_url}/zones/{zone_id}/subscription", - headers=headers, - json={ - "rate_plan": {"id": "PARTNERS_ENT"}, - "frequency": "annual" - } - ) - subscription_response.raise_for_status() - subscription_response_json = subscription_response.json() - logger.info(f"Created subscription: {subscription_response_json}") - - def create_prototype_dns_record(self, dns_record_dict): - print(f"what is the key? {settings.SECRET_REGISTRY_TENANT_KEY}") - - # Don't execute this function on any other domain - if settings.IS_PRODUCTION and self.name != "igorville.gov": - logger.warning(f"create_dns_record was called for domain {self.name}") - return None - - # Cloudflare API endpoints - base_url = "https://api.cloudflare.com/client/v4" - headers = { - "Authorization": f"Bearer {settings.SECRET_REGISTRY_TENANT_KEY}", - "Content-Type": "application/json" - } - - # TODO - check if these things exist before doing stuff - # 1. Get tenant details - # Note: we can grab this more generally but lets be specific to keep things safe. - tenant_id = settings.SECRET_REGISTRY_TENANT_ID - - # 2. Create account under tenant - account_id = self.create_prototype_account(base_url, headers, tenant_id) - - # 3. Create zone under account - zone_id = self.create_prototype_zone(base_url, headers, account_id) - - # 4. Add zone subscription - self.create_prototype_subscription(base_url, headers, zone_id) - - # 5. Create DNS record - dns_response = requests.post( - f"{base_url}/zones/{zone_id}/dns_records", - headers=headers, - json=dns_record_dict - ) - dns_response.raise_for_status() - dns_response_json = dns_response.json() - logger.info(f"Created DNS record: {dns_response_json}") - - return { - "tenant_id": tenant_id, - "account_id": account_id, - "zone_id": zone_id, - "dns_record_id": dns_response_json["result"]["id"] - } - def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.YEAR): """ Renew the domain to a length and unit of time relative to the current diff --git a/src/registrar/templates/prototype_domain_dns.html b/src/registrar/templates/prototype_domain_dns.html index e588922cf..76cc6cb5c 100644 --- a/src/registrar/templates/prototype_domain_dns.html +++ b/src/registrar/templates/prototype_domain_dns.html @@ -12,6 +12,7 @@

This is a prototype that demonstrates adding an 'A' record to igorville.gov. Do note that this just adds records, but does not update or delete existing ones. + You can only use this on igorville.gov, domainops.gov, and dns.gov.

diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 85f66b07b..6d5ff272d 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 @@ -517,27 +517,125 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): form = self.get_form() if form.is_valid(): try: - # Format the DNS record according to Cloudflare's API requirements - dns_record = { - "type": "A", - "name": form.cleaned_data["name"], - "content": form.cleaned_data["content"], - "ttl": int(form.cleaned_data["ttl"]), - "comment": f"Test record (will eventually need to clean up)" - } + if settings.IS_PRODUCTION and self.object.name != "igorville.gov": + raise Exception(f"create dns record was called for domain {self.name}") - result = self.object.create_prototype_dns_record(dns_record) - - if result: # Assuming create_prototype_dns_record returns the response data - messages.success( - request, - f"DNS A record '{form.cleaned_data['name']}' created successfully." - ) - else: + valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] + if not settings.IS_PRODUCTION and self.object.name not in valid_domains: messages.error( request, - "Failed to create DNS A record. Please try again." + f"Can only create DNS records for: {valid_domains}." + " Create one in a test environment if it doesn't already exist." ) + return super().post(request) + + 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) + tenant_response.raise_for_status() + tenant_response_json = tenant_response.json() + logger.info(f"Found tenant: {tenant_response_json}") + tenant_id = tenant_response_json["result"][0]["tenant_tag"] + + # 2. Create account under tenant + + # Check to see if the account already exists. Filters accounts by tenant_id. + account_name = f"account-{self.object.name}" + params = {"tenant_id": tenant_id} + + account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params) + account_response.raise_for_status() + account_response_json = account_response.json() + print(f"account stuff: {account_response_json}") + + # See if we already made an account + account_id = None + accounts = account_response_json.get("result", []) + for account in accounts: + if account.get("name") == account_name: + account_id = account.get("id") + print(f"Found it! Account: {account_name} (ID: {account_id})") + break + + # 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} + } + ) + account_response.raise_for_status() + account_response_json = account_response.json() + logger.info(f"Created account: {account_response_json}") + account_id = account_response_json["result"]["id"] + + # # 3. Create zone under account + # zone_response = requests.post( + # f"{base_url}/zones", + # headers=headers, + # json={ + # "name": self.name, + # "account": {"id": account_id}, + # "type": "full" + # } + # ) + # zone_response.raise_for_status() + # zone_response_json = zone_response.json() + # zone_id = zone_response_json["result"]["id"] + # logger.info(f"Created zone: {zone_response_json}") + + # # 4. Add zone subscription + # subscription_response = requests.post( + # f"{base_url}/zones/{zone_id}/subscription", + # headers=headers, + # json={ + # "rate_plan": {"id": "PARTNERS_ENT"}, + # "frequency": "annual" + # } + # ) + # subscription_response.raise_for_status() + # subscription_response_json = subscription_response.json() + # logger.info(f"Created subscription: {subscription_response_json}") + + + # # 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)" + # } + # ) + # dns_response.raise_for_status() + # dns_response_json = dns_response.json() + # logger.info(f"Created DNS record: {dns_response_json}") + + # if dns_response_json and "name" in dns_response_json: + # messages.success( + # request, + # f"DNS A record '{form.cleaned_data['name']}' created successfully." + # ) + # else: + # messages.error( + # request, + # "Failed to create DNS A record. Please try again." + # ) except Exception as err: logger.error(f"Error creating DNS A record for {self.object.name}: {err}") From 470a5e4904ad66d2cdeac9da2938cab5496d6e82 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:06:22 -0700 Subject: [PATCH 07/17] creating / getting zone --- src/registrar/views/domain.py | 71 ++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6d5ff272d..46beb6e2b 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -522,12 +522,10 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] if not settings.IS_PRODUCTION and self.object.name not in valid_domains: - messages.error( - request, + raise Exception( f"Can only create DNS records for: {valid_domains}." " Create one in a test environment if it doesn't already exist." ) - return super().post(request) base_url = "https://api.cloudflare.com/client/v4" headers = { @@ -539,29 +537,30 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # 1. Get tenant details tenant_response = requests.get(f"{base_url}/user/tenants", headers=headers, params=params) - tenant_response.raise_for_status() tenant_response_json = tenant_response.json() logger.info(f"Found tenant: {tenant_response_json}") tenant_id = tenant_response_json["result"][0]["tenant_tag"] + tenant_response.raise_for_status() - # 2. Create account under tenant + # 2. Create or get a account under tenant - # Check to see if the account already exists. Filters accounts by tenant_id. + # 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} + params = {"tenant_id": tenant_id, "name": account_name} account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params) - account_response.raise_for_status() account_response_json = account_response.json() - print(f"account stuff: {account_response_json}") + logger.debug(f"account get: {account_response_json}") + account_response.raise_for_status() - # See if we already made an account + # See if we already made an account. + # This doesn't need to be a for loop (1 record or 0) but alas, here we are account_id = None accounts = account_response_json.get("result", []) for account in accounts: if account.get("name") == account_name: account_id = account.get("id") - print(f"Found it! Account: {account_name} (ID: {account_id})") + logger.debug(f"Found it! Account: {account_name} (ID: {account_id})") break # If we didn't, create one @@ -575,25 +574,45 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): "unit": {"id": tenant_id} } ) - account_response.raise_for_status() account_response_json = account_response.json() logger.info(f"Created account: {account_response_json}") account_id = account_response_json["result"]["id"] + account_response.raise_for_status() - # # 3. Create zone under account - # zone_response = requests.post( - # f"{base_url}/zones", - # headers=headers, - # json={ - # "name": self.name, - # "account": {"id": account_id}, - # "type": "full" - # } - # ) - # zone_response.raise_for_status() - # zone_response_json = zone_response.json() - # zone_id = zone_response_json["result"]["id"] - # logger.info(f"Created zone: {zone_response_json}") + # 3. Create or get a zone under account + + # Try to find an existing zone first by searching on the current id + zone_name = f"zone-{self.object.name}" + params = {"account.id": account_id, "name": zone_name} + zone_response = requests.get(f"{base_url}/zones", headers=headers, params=params) + zone_response_json = zone_response.json() + logger.debug(f"get zone: {zone_response_json}") + zone_response.raise_for_status() + + # Get the zone id + zone_id = None + zones = zone_response_json.get("result", []) + for zone in zones: + if zone.get("name") == zone_name: + zone_id = zone.get("id") + logger.debug(f"Found it! Zone: {zone_name} (ID: {zone_id})") + break + + # 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" + } + ) + zone_response_json = zone_response.json() + logger.info(f"Created zone: {zone_response_json}") + zone_id = zone_response_json["result"]["id"] + zone_response.raise_for_status() # # 4. Add zone subscription # subscription_response = requests.post( From cf0f81ffae954d888b3d9dba524fdc9031486fb7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:24:28 -0700 Subject: [PATCH 08/17] create / get zone subscription --- src/registrar/views/domain.py | 37 +++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 46beb6e2b..b836cda44 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -614,18 +614,31 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): zone_id = zone_response_json["result"]["id"] zone_response.raise_for_status() - # # 4. Add zone subscription - # subscription_response = requests.post( - # f"{base_url}/zones/{zone_id}/subscription", - # headers=headers, - # json={ - # "rate_plan": {"id": "PARTNERS_ENT"}, - # "frequency": "annual" - # } - # ) - # subscription_response.raise_for_status() - # subscription_response_json = subscription_response.json() - # logger.info(f"Created subscription: {subscription_response_json}") + # 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) + 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" + } + ) + 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 From a17368be2454fcae934f359aed4918b7a1112a79 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:09:01 -0700 Subject: [PATCH 09/17] WIP dns record stuff --- src/registrar/views/domain.py | 63 +++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b836cda44..61a6f21cf 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -515,6 +515,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): """Handle form submission.""" self.object = self.get_object() form = self.get_form() + error_messages = [] if form.is_valid(): try: if settings.IS_PRODUCTION and self.object.name != "igorville.gov": @@ -540,6 +541,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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 @@ -551,6 +553,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params) 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. @@ -577,16 +580,18 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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 = f"zone-{self.object.name}" + 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) 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 @@ -611,8 +616,9 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): ) zone_response_json = zone_response.json() logger.info(f"Created zone: {zone_response_json}") - zone_id = zone_response_json["result"]["id"] + errors = zone_response_json.get("errors", []) zone_response.raise_for_status() + zone_id = zone_response_json.get("result", {}).get("id") # 4. Add or get a zone subscription @@ -643,38 +649,37 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # # 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)" - # } - # ) - # dns_response.raise_for_status() - # dns_response_json = dns_response.json() - # logger.info(f"Created DNS record: {dns_response_json}") - - # if dns_response_json and "name" in dns_response_json: - # messages.success( - # request, - # f"DNS A record '{form.cleaned_data['name']}' created successfully." - # ) - # else: - # messages.error( - # request, - # "Failed to create DNS A record. Please try again." - # ) - + 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)" + } + ) + 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() + messages.success( + request, + f"DNS A record '{form.cleaned_data['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 while creating the DNS A record: {err}" + f"An error occurred: {err}" ) + finally: + if errors: + messages.error( + request, + f"Request errors: {errors}" + ) return super().post(request) From e1a37fbc35475b4375eb79ae1dcaf1aacb1fddff Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:49:19 -0700 Subject: [PATCH 10/17] cleanup + test on sandbox --- src/registrar/models/domain.py | 3 - src/registrar/templates/domain_dns.html | 2 +- .../templates/prototype_domain_dns.html | 9 +- src/registrar/views/domain.py | 83 ++++++++----------- 4 files changed, 41 insertions(+), 56 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index a96f9f6ec..cc600e1ce 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1,11 +1,9 @@ from itertools import zip_longest import logging import ipaddress -import requests import re from datetime import date from typing import Optional -from django.conf import settings from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models @@ -239,7 +237,6 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" - return True if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index cf6d2ef7c..88ecc808b 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -36,7 +36,7 @@ {% url 'domain-dns-dnssec' pk=domain.id as url %}
  • DNSSEC
  • - {% if dns_prototype_flag %} + {% if dns_prototype_flag and is_valid_domain_for_prototype %}
  • Prototype DNS record creator
  • {% endif %} diff --git a/src/registrar/templates/prototype_domain_dns.html b/src/registrar/templates/prototype_domain_dns.html index 76cc6cb5c..4cbeb95dd 100644 --- a/src/registrar/templates/prototype_domain_dns.html +++ b/src/registrar/templates/prototype_domain_dns.html @@ -7,12 +7,15 @@ {% include "includes/form_errors.html" with form=form %} -

    Add a cloudflare DNS record

    +

    Add DNS records

    - This is a prototype that demonstrates adding an 'A' record to igorville.gov. + 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 on igorville.gov, domainops.gov, and dns.gov. + + On non-production environments, you can only use this on igorville.gov, domainops.gov, and dns.gov. + On production environments, you can only use this on igorville.gov. +

    diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 61a6f21cf..ea5de093e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -459,25 +459,29 @@ class DomainDNSView(DomainBaseView): def get_context_data(self, **kwargs): """Adds custom context.""" context = super().get_context_data(**kwargs) + object = self.get_object() context["dns_prototype_flag"] = flag_is_active_for_user(self.request.user, "dns_prototype_flag") + + context["is_valid_domain_for_prototype"] = True + x = object.name == "igorville.gov" + print(f"what is the object? {object.name} and equal? {x}") + if settings.IS_PRODUCTION: + context["is_valid_domain_for_prototype"] = object.name == "igorville.gov" + 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" - ) + 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=[ @@ -488,26 +492,28 @@ class PrototypeDomainDNSRecordForm(forms.Form): (3600, "1 hour"), (7200, "2 hours"), (18000, "5 hours"), - (43200, "12 hours"), - (86400, "1 day") + (43200, "12 hours"), + (86400, "1 day"), ], initial=1, ) + class PrototypeDomainDNSRecordView(DomainFormBaseView): template_name = "prototype_domain_dns.html" form_class = PrototypeDomainDNSRecordForm + 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 return True - + def get_success_url(self): return reverse("prototype-domain-dns", kwargs={"pk": self.object.pk}) @@ -520,22 +526,22 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): try: if settings.IS_PRODUCTION and self.object.name != "igorville.gov": raise Exception(f"create dns record was called for domain {self.name}") - + valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] if not settings.IS_PRODUCTION and self.object.name not in valid_domains: raise Exception( f"Can only create DNS records for: {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" + "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) tenant_response_json = tenant_response.json() @@ -557,7 +563,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): account_response.raise_for_status() # See if we already made an account. - # This doesn't need to be a for loop (1 record or 0) but alas, here we are + # This maybe doesn't need to be a for loop (1 record or 0) but alas, here we are account_id = None accounts = account_response_json.get("result", []) for account in accounts: @@ -565,17 +571,13 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): account_id = account.get("id") logger.debug(f"Found it! Account: {account_name} (ID: {account_id})") break - + # 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} - } + json={"name": account_name, "type": "enterprise", "unit": {"id": tenant_id}}, ) account_response_json = account_response.json() logger.info(f"Created account: {account_response_json}") @@ -587,7 +589,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # 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} + params = {"account.id": account_id, "name": zone_name} zone_response = requests.get(f"{base_url}/zones", headers=headers, params=params) zone_response_json = zone_response.json() logger.debug(f"get zone: {zone_response_json}") @@ -602,23 +604,19 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): zone_id = zone.get("id") logger.debug(f"Found it! Zone: {zone_name} (ID: {zone_id})") break - + # 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" - } + json={"name": zone_name, "account": {"id": account_id}, "type": "full"}, ) 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() - zone_id = zone_response_json.get("result", {}).get("id") # 4. Add or get a zone subscription @@ -628,17 +626,14 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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): + # 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" - } + json={"rate_plan": {"id": "PARTNERS_ENT"}, "frequency": "annual"}, ) subscription_response.raise_for_status() subscription_response_json = subscription_response.json() @@ -646,7 +641,6 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): else: subscription_response.raise_for_status() - # # 5. Create DNS record # # Format the DNS record according to Cloudflare's API requirements dns_response = requests.post( @@ -657,29 +651,20 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): "name": form.cleaned_data["name"], "content": form.cleaned_data["content"], "ttl": int(form.cleaned_data["ttl"]), - "comment": "Test record (will need clean up)" - } + "comment": "Test record (will need clean up)", + }, ) 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() - messages.success( - request, - f"DNS A record '{form.cleaned_data['name']}' created successfully." - ) + messages.success(request, f"DNS A record '{form.cleaned_data['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}" - ) + messages.error(request, f"An error occurred: {err}") finally: if errors: - messages.error( - request, - f"Request errors: {errors}" - ) + messages.error(request, f"Request errors: {errors}") return super().post(request) From 33e0c769abaf6092f884a7ba283a3d596106c189 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:00:50 -0700 Subject: [PATCH 11/17] Add request timeout (thanks, linter!) --- src/registrar/views/domain.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index ea5de093e..851a7c266 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -543,7 +543,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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) + 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"] @@ -556,7 +556,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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) + 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", []) @@ -578,6 +578,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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}") @@ -590,7 +591,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # 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) + 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", []) @@ -611,6 +612,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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}") @@ -621,7 +623,9 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # 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) + 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}") @@ -634,6 +638,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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() @@ -653,6 +658,7 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): "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}") From 87a3dee4ed9e56ff28f469c8e5f4151fb47e5e77 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:27:54 -0700 Subject: [PATCH 12/17] Make flag appear by default --- src/registrar/admin.py | 8 ++++++++ src/registrar/views/domain.py | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 40d4befb5..1b62f74d6 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3579,6 +3579,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/views/domain.py b/src/registrar/views/domain.py index 851a7c266..34aac37f5 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -459,15 +459,7 @@ class DomainDNSView(DomainBaseView): def get_context_data(self, **kwargs): """Adds custom context.""" context = super().get_context_data(**kwargs) - object = self.get_object() context["dns_prototype_flag"] = flag_is_active_for_user(self.request.user, "dns_prototype_flag") - - context["is_valid_domain_for_prototype"] = True - x = object.name == "igorville.gov" - print(f"what is the object? {object.name} and equal? {x}") - if settings.IS_PRODUCTION: - context["is_valid_domain_for_prototype"] = object.name == "igorville.gov" - return context From 5f02ae7f818852dbbe066fd69a30290fccb4a372 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:52:07 -0700 Subject: [PATCH 13/17] Add some documentation --- docs/developer/README.md | 15 +++++++++++++++ src/registrar/templates/domain_dns.html | 2 +- src/registrar/templates/prototype_domain_dns.html | 11 ++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) 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/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index 88ecc808b..cf6d2ef7c 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -36,7 +36,7 @@ {% url 'domain-dns-dnssec' pk=domain.id as url %}
  • DNSSEC
  • - {% if dns_prototype_flag and is_valid_domain_for_prototype %} + {% if dns_prototype_flag %}
  • Prototype DNS record creator
  • {% endif %} diff --git a/src/registrar/templates/prototype_domain_dns.html b/src/registrar/templates/prototype_domain_dns.html index 4cbeb95dd..656e0685a 100644 --- a/src/registrar/templates/prototype_domain_dns.html +++ b/src/registrar/templates/prototype_domain_dns.html @@ -12,12 +12,13 @@

    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. - - On non-production environments, you can only use this on igorville.gov, domainops.gov, and dns.gov. - On production environments, you can only use this on igorville.gov. -

    - +

    + 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 %} From 5689947dd818d1e1d0ea410b3ee6ba2a9ec3951f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:58:10 -0700 Subject: [PATCH 14/17] perm check --- src/registrar/templates/domain_dns.html | 2 +- src/registrar/views/domain.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index cf6d2ef7c..c8ece2e32 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -36,7 +36,7 @@ {% url 'domain-dns-dnssec' pk=domain.id as url %}
  • DNSSEC
  • - {% if dns_prototype_flag %} + {% if dns_prototype_flag and is_valid_domain %}
  • Prototype DNS record creator
  • {% endif %} diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 34aac37f5..279cca072 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -455,11 +455,13 @@ 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 @@ -494,6 +496,7 @@ class PrototypeDomainDNSRecordForm(forms.Form): 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() @@ -504,6 +507,10 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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): @@ -513,16 +520,15 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): """Handle form submission.""" self.object = self.get_object() form = self.get_form() - error_messages = [] + 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}") - valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"] - if not settings.IS_PRODUCTION and self.object.name not in valid_domains: + if not settings.IS_PRODUCTION and self.object.name not in self.valid_domains: raise Exception( - f"Can only create DNS records for: {valid_domains}." + f"Can only create DNS records for: {self.valid_domains}." " Create one in a test environment if it doesn't already exist." ) From fbfde73276af60927be0aa083c4ff8b19476fb48 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:03:55 -0700 Subject: [PATCH 15/17] temp change to create igorville --- src/registrar/models/domain.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index cc600e1ce..2ea78ff10 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -237,6 +237,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" + return True if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in From 00aee2a4fe9c2687538e180d22514af1578a2500 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:09:03 -0700 Subject: [PATCH 16/17] Update domain.py --- src/registrar/models/domain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 2ea78ff10..cc600e1ce 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -237,7 +237,6 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" - return True if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in From f32d8c7a3138f130e9f4fc12fe5eac173b3433eb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:29:47 -0700 Subject: [PATCH 17/17] fix linting issue --- src/registrar/views/domain.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 279cca072..cb3da1f83 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -516,6 +516,10 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): 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() @@ -562,13 +566,8 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): # 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 - account_id = None accounts = account_response_json.get("result", []) - for account in accounts: - if account.get("name") == account_name: - account_id = account.get("id") - logger.debug(f"Found it! Account: {account_name} (ID: {account_id})") - break + account_id = self.find_by_name(accounts, account_name) # If we didn't, create one if not account_id: @@ -596,13 +595,8 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): zone_response.raise_for_status() # Get the zone id - zone_id = None zones = zone_response_json.get("result", []) - for zone in zones: - if zone.get("name") == zone_name: - zone_id = zone.get("id") - logger.debug(f"Found it! Zone: {zone_name} (ID: {zone_id})") - break + zone_id = self.find_by_name(zones, zone_name) # Create one if it doesn't presently exist if not zone_id: @@ -662,7 +656,8 @@ class PrototypeDomainDNSRecordView(DomainFormBaseView): logger.info(f"Created DNS record: {dns_response_json}") errors = dns_response_json.get("errors", []) dns_response.raise_for_status() - messages.success(request, f"DNS A record '{form.cleaned_data['name']}' created successfully.") + 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}")