From 809005feea63c36961fdb0d399b2974c95c94fba Mon Sep 17 00:00:00 2001
From: Robert Alexander <36200436+robalexdev@users.noreply.github.com>
Date: Sat, 23 Nov 2024 21:47:00 +0000
Subject: [PATCH] Working with LEGO
---
docker-compose.yml | 1 +
localcert/domains/pdns.py | 47 +++++++++++++------
localcert/domains/subdomain_utils.py | 1 -
.../domains/templates/domain_detail.html | 2 +-
localcert/domains/utils.py | 11 +++++
localcert/domains/views.py | 36 +++++++-------
localcert/requirements-dev.txt | 2 +-
localcert/requirements.txt | 22 +++++++--
8 files changed, 84 insertions(+), 38 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index f6543db..4d42c79 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -29,6 +29,7 @@ services:
- LOCALCERT_WEB_PDNS_DNS_PORT
- LOCALCERT_WEB_PDNS_HOST
- LOCALCERT_WEB_PGSQL_HOST
+ - CLOUDFLARE_TOKEN
- POSTGRES_PASSWORD
- POSTGRES_USER
networks:
diff --git a/localcert/domains/pdns.py b/localcert/domains/pdns.py
index 64530ff..e0651f5 100644
--- a/localcert/domains/pdns.py
+++ b/localcert/domains/pdns.py
@@ -1,4 +1,5 @@
import logging
+import os
from .utils import CustomExceptionServerError
from datetime import datetime
@@ -16,18 +17,19 @@
client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_TOKEN"))
+def get_zone_id(domain: str) -> str:
+ for k, v in ZONE_IDS.items():
+ if domain.endswith(f".{k}"):
+ return v
+ else:
+ assert False, "Unknown domain"
+
# TODO: Some records are set by wildcard, hardcode these
def pdns_describe_domain(domain: str) -> dict:
assert domain.endswith(".")
logging.debug(f"[PDNS] Describe {domain}")
- for k, v in ZONE_IDS.items():
- if domain.endswith(f".{k}")
- zone_id = v
- break
- else:
- # Ooops
- return {}
+ zone_id = get_zone_id(domain)
# CF doesn't use trailing dot
domain = domain[:-1]
@@ -47,19 +49,33 @@ def pdns_describe_domain(domain: str) -> dict:
).result
results.extend(r2)
- rrsets = []
+ # Convert CF results to look like PDNS JSON
+ results_by_name = {}
for result in results:
- rrset.append({
- "type": "TXT",
- "name": result.name,
+ if result.name not in results_by_name:
+ results_by_name[result.name] = []
+ results_by_name[result.name].append({
"content": result.content,
- "ttl": result.ttl,
+ "created": result.created_on,
})
+
+ rrsets = []
+ for name, records in results_by_name.items():
+ records.sort(key=lambda r: r['created'])
+ records = [ {'content': _['content']} for _ in records ]
+
+ rrsets.append({
+ "type": "TXT",
+ "name": name,
+ "records": records,
+ })
+
+ logging.debug(f"[PDNS] RRSets: {results} {rrsets}")
return { "rrsets": rrsets }
def pdns_replace_rrset(
- zone_name: str, rr_name: str, rr_type: str, ttl: int, record_contents: List[str]
+ zone_name: str, rr_name: str, rr_type: str, record_contents: List[str]
):
"""
record_contents - Records from least recently added
@@ -68,11 +84,12 @@ def pdns_replace_rrset(
assert rr_name.endswith(zone_name)
assert rr_type == "TXT"
+ zone_id = get_zone_id(zone_name)
+
# CF doesn't use trailing dot
rr_name = rr_name[:-1]
# Collect the existing content
- zone_id = ZONE_IDS[zone_name]
results = client.dns.records.list(
zone_id=zone_id,
name=rr_name,
@@ -82,12 +99,14 @@ def pdns_replace_rrset(
for record in results:
if record.content not in record_contents:
# Delete records that are no longer needed
+ logging.debug(f"No longer need: {record.content} || {record_contents}")
client.dns.records.delete(
zone_id=zone_id,
dns_record_id=record.id,
)
else:
# Don't alter records that already exist
+ logging.debug(f"Keeping: {record.content} || {record_contents}")
record_contents.remove(record.content)
for content in record_contents:
diff --git a/localcert/domains/subdomain_utils.py b/localcert/domains/subdomain_utils.py
index 27467e8..fd2c6f2 100644
--- a/localcert/domains/subdomain_utils.py
+++ b/localcert/domains/subdomain_utils.py
@@ -13,7 +13,6 @@
DEFAULT_SPF_POLICY,
)
from .models import Zone, ZoneApiKey
-from .pdns import pdns_replace_rrset
from .utils import remove_trailing_dot
diff --git a/localcert/domains/templates/domain_detail.html b/localcert/domains/templates/domain_detail.html
index 3079a2b..820a2aa 100644
--- a/localcert/domains/templates/domain_detail.html
+++ b/localcert/domains/templates/domain_detail.html
@@ -45,7 +45,7 @@
Records
{{ rrset.name | strip_domain_name }}
- {{ rrset.ttl | namedDuration }}
+ Auto
|
{{ record.content }}
diff --git a/localcert/domains/utils.py b/localcert/domains/utils.py
index 59e5b30..2fc9d5a 100644
--- a/localcert/domains/utils.py
+++ b/localcert/domains/utils.py
@@ -119,3 +119,14 @@ def subdomain_name(value: str) -> str:
if value.endswith(f".{suffix}"):
return value.removesuffix(f".{suffix}")
assert False # pragma: no cover
+
+
+def domains_equal(a: str, b: str) -> bool:
+ if a.endswith('.'):
+ a = a[:-1]
+ if b.endswith('.'):
+ b = b[:-1]
+ return a == b
+
+
+
diff --git a/localcert/domains/views.py b/localcert/domains/views.py
index ffd1934..bbabc58 100644
--- a/localcert/domains/views.py
+++ b/localcert/domains/views.py
@@ -8,7 +8,7 @@
from django.utils import timezone
from requests.structures import CaseInsensitiveDict
-from .subdomain_utils import Credentials, create_instant_subdomain, set_up_pdns_for_zone
+from .subdomain_utils import Credentials, create_instant_subdomain
from .validators import validate_acme_dns01_txt_value, validate_label
from .network import dns_query_A, dns_query_TXT
@@ -42,6 +42,7 @@
domain_limit_for_user,
sort_records_key,
build_url,
+ domains_equal,
)
from uuid import uuid4
from django.contrib import messages
@@ -139,7 +140,6 @@ def register_subdomain(
zone_name = form.cleaned_data["zone_name"] # synthetic field
logging.info(f"Creating domain {zone_name} for user {request.user.id}...")
- set_up_pdns_for_zone(zone_name, parent_zone)
newZone = Zone.objects.create(
name=zone_name,
owner=request.user,
@@ -385,32 +385,24 @@ def api_instant_subdomain(
def api_health(
_: HttpRequest,
) -> JsonResponse:
- # Check a random host name, it should not resolve
- # Random name is used to ensure uncached responses
- try:
- dns_query_A(str(uuid4()) + ".localhostcert.net")
- logging.warning("Query unexpectedly resolved")
- ext_dns_a_healthy = False
- except dns.resolver.NXDOMAIN:
- ext_dns_a_healthy = True
- # Check a TXT for a known domain (short TTL)
+ # Check a SPF record
try:
txt_result = dns_query_TXT(
- "_acme-challenge.test-txt-lookup-known-value.localhostcert.net"
+ "localhostcert.net"
)
ext_dns_txt_healthy = (
- b"testtestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" in txt_result
+ b"v=spf1 -all" in txt_result
)
except Exception as e:
logging.warning(e)
ext_dns_txt_healthy = False
- healthy = ext_dns_a_healthy and ext_dns_txt_healthy
+ healthy = ext_dns_txt_healthy
status = 200 if healthy else 500
return JsonResponse(
{
"healthy": healthy,
- "a": ext_dns_a_healthy,
+ "a": True,
"b": ext_dns_txt_healthy,
},
status=status,
@@ -1014,6 +1006,7 @@ def update_txt_record_helper(
):
new_content = f'"{rr_content}"' # Normalize
existing_user_defined = get_existing_txt_records(zone_name, rr_name)
+ existing_user_defined = [ _['content'] for _ in existing_user_defined ]
if edit_action == EditActionEnum.ADD:
if any([new_content == existing for existing in existing_user_defined]):
@@ -1039,13 +1032,14 @@ def update_txt_record_helper(
item for item in existing_user_defined if item != new_content
]
if len(new_content_set) == len(existing_user_defined):
+ logging.debug(f"AAAAAAAAA {zone_name} {rr_name} || {existing_user_defined} || {new_content}")
if is_web_request:
messages.warning(request, "Nothing was removed")
return
logging.info(f"Updating RRSET {rr_name} TXT with {len(new_content_set)} values")
# Replace to update the content
- pdns_replace_rrset(zone_name, rr_name, "TXT", 1, new_content_set)
+ pdns_replace_rrset(zone_name, rr_name, "TXT", new_content_set)
if is_web_request:
if edit_action == EditActionEnum.ADD:
messages.success(request, "Record added")
@@ -1053,3 +1047,13 @@ def update_txt_record_helper(
messages.success(request, "Record removed")
+def get_existing_txt_records(zone_name: str, rr_name: str) -> List[str]:
+ details = pdns_describe_domain(rr_name)
+ existing_records = []
+ if details["rrsets"]:
+ for rrset in details["rrsets"]:
+ if domains_equal(rrset['name'], rr_name) and rrset["type"] == "TXT":
+ existing_records = rrset["records"]
+ break
+
+ return existing_records
diff --git a/localcert/requirements-dev.txt b/localcert/requirements-dev.txt
index eb65cee..2ab272f 100644
--- a/localcert/requirements-dev.txt
+++ b/localcert/requirements-dev.txt
@@ -1,6 +1,6 @@
beautifulsoup4==4.12.3
black==23.10.1
-coverage==7.6.4
+coverage==7.6.7
dnspython==2.7.0
flake8==7.1.1
pip-upgrader==1.4.15
diff --git a/localcert/requirements.txt b/localcert/requirements.txt
index cc9d4a3..d4d357e 100644
--- a/localcert/requirements.txt
+++ b/localcert/requirements.txt
@@ -1,21 +1,33 @@
+Django==5.1.3
+PyJWT==2.10.0
+annotated-types==0.7.0
+anyio==4.6.2.post1
asgiref==3.8.1
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.4.0
+cloudflare==3.1.0
cryptography==43.0.3
defusedxml==0.7.1
-Django==5.1.2
-django-allauth==65.1.0
+distro==1.9.0
+django-allauth==65.2.0
django-csp==3.8
dnspython==2.7.0
gunicorn==23.0.0
+h11==0.14.0
+httpcore==1.0.7
+httpx==0.27.2
idna==3.10
oauthlib==3.2.2
psycopg2-binary==2.9.10
pycparser==2.22
-PyJWT==2.9.0
+pydantic==2.9.0
+pydantic_core==2.23.2
python3-openid==3.2.0
-requests==2.32.3
requests-oauthlib==2.0.0
-sqlparse==0.5.1
+requests==2.32.3
+sniffio==1.3.1
+sqlparse==0.5.2
+typing_extensions==4.12.2
+tzdata==2024.2
urllib3==2.2.3
|