mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-21 18:25:58 +02:00
Merge branch 'main' into subissue-template
This commit is contained in:
commit
658a72da3d
14 changed files with 321 additions and 9 deletions
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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----------------------------------------------###
|
||||
|
|
|
@ -293,6 +293,7 @@ urlpatterns = [
|
|||
name="todo",
|
||||
),
|
||||
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
|
||||
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
|
||||
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
|
||||
path(
|
||||
"domain/<int:pk>/dns",
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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)")
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
<p>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.</p>
|
||||
|
||||
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
|
||||
|
||||
|
||||
{% url 'domain-dns-nameservers' pk=domain.id as url %}
|
||||
<ul class="usa-list">
|
||||
|
@ -35,6 +36,9 @@
|
|||
|
||||
{% url 'domain-dns-dnssec' pk=domain.id as url %}
|
||||
<li><a href="{{ url }}">DNSSEC</a></li>
|
||||
{% if dns_prototype_flag and is_valid_domain %}
|
||||
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% endblock %} {# domain_content #}
|
||||
|
|
34
src/registrar/templates/prototype_domain_dns.html
Normal file
34
src/registrar/templates/prototype_domain_dns.html
Normal file
|
@ -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 %}
|
||||
|
||||
<h1>Add DNS records</h1>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
You can only use this functionality on a limited set of domains:
|
||||
<strong>
|
||||
igorville.gov, dns.gov (non-prod), and domainops.gov (non-prod).
|
||||
</strong>
|
||||
</p>
|
||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
||||
{% csrf_token %}
|
||||
{% input_with_errors form.name %}
|
||||
{% input_with_errors form.content %}
|
||||
{% input_with_errors form.ttl %}
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>
|
||||
Add record
|
||||
</button>
|
||||
</form>
|
||||
{% endblock %} {# domain_content #}
|
|
@ -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)
|
||||
|
|
|
@ -13,6 +13,7 @@ from .domain import (
|
|||
DomainAddUserView,
|
||||
DomainInvitationCancelView,
|
||||
DomainDeleteUserView,
|
||||
PrototypeDomainDNSRecordView,
|
||||
)
|
||||
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||
from .health import *
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue